diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index f9b2e8e..f68c53e 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -131,13 +131,6 @@ jobs: cd ci cargo run --bin check-asset - - name: Check core - uses: actions/checkout@v3 - with: - repository: 'niuhuan/jasmine-rs-core' - token: ${{ secrets.GH_TOKEN }} - path: 'native' - - name: Cache Flutter dependencies (Linux/Android) if: steps.check_asset.outputs.skip_build != 'true' && matrix.config.host == 'ubuntu-latest' uses: actions/cache@v3 diff --git a/lib/screens/init_screen.dart b/lib/screens/init_screen.dart index c16e663..cefff76 100644 --- a/lib/screens/init_screen.dart +++ b/lib/screens/init_screen.dart @@ -62,34 +62,14 @@ class _InitScreenState extends State { await methods.init(); await initConfigs(); print("STATE : ${loginStatus}"); - if (!currentPassed()) { - Future.delayed(Duration.zero, () async { - await webDavSyncAuto(context); - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (BuildContext context) { - return const CalculatorScreen(); - }), - ); - }); - } else if (loginStatus == LoginStatus.notSet) { - Future.delayed(Duration.zero, () async { - await webDavSyncAuto(context); - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (BuildContext context) { - return firstLoginScreen; - }), - ); - }); - } else { - Future.delayed(Duration.zero, () async { - await webDavSyncAuto(context); - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (BuildContext context) { - return const AppScreen(); - }), - ); - }); - } + Future.delayed(Duration.zero, () async { + await webDavSyncAuto(context); + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (BuildContext context) { + return const AppScreen(); + }), + ); + }); } catch (e, st) { print("$e\n$st"); defaultToast(context, "初始化失败, 请设置网络"); diff --git a/native/jmbackend/.gitignore b/native/jmbackend/.gitignore new file mode 100644 index 0000000..0bccdd7 --- /dev/null +++ b/native/jmbackend/.gitignore @@ -0,0 +1,9 @@ +target/ +Cargo.lock + +platforms/android/jmbackend +platforms/android/jmcomic-rs +platforms/test/ + +/.idea/ +*.iml diff --git a/native/jmbackend/Cargo.toml b/native/jmbackend/Cargo.toml new file mode 100644 index 0000000..316731c --- /dev/null +++ b/native/jmbackend/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "jmbackend" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +jmcomic = { path = "../jmcomic-rs" } +lazy_static = "1.4.0" +sea-orm = { version = "0.12.4", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"], default-features = false } +sea-orm-migration = { version = "0.12.4", features = ["sqlx-sqlite", "runtime-tokio-rustls"], default-features = false } +chrono = "0.4.31" +libc = "0.2.149" +bytes = "1.5.0" +tokio = { version = "1.33.0", features = ["full"] } +reqwest = { version = "0.11.22", features = ["socks", "stream", "rustls", "tokio-rustls", "rustls-tls"], default-features = false } +hex = "0.4.3" +md5 = "0.7.0" +image = { version = "0.24.7", features = ["png", "jpeg", "bmp", "gif", "webp"] } +rand = "0.8.5" +itertools = "0.11.0" +async-trait = "0.1.74" +anyhow = "1.0.75" +rsa = "0.6.1" +base64 = "0.13.0" +once_cell = "1.18.0" +regex = "1.10.2" +num-iter = "0.1.43" +futures-util = "0.3.29" +grouping_by = "0.2.2" +tokio-util = { version = "0.7.10", features = ["full"] } +async_zip = { version = "0.0.9", features = ["deflate"], default-features = false } +serde = { version = "1.0.190", features = ["derive"] } +serde_derive = "1.0.190" +serde_json = "1.0.108" + +[features] diff --git a/native/jmbackend/platforms/android/Cargo.toml b/native/jmbackend/platforms/android/Cargo.toml new file mode 100644 index 0000000..e718e9b --- /dev/null +++ b/native/jmbackend/platforms/android/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } +jni = "0.21.0" + +[lib] +crate-type = ["cdylib"] diff --git a/native/jmbackend/platforms/android/src/lib.rs b/native/jmbackend/platforms/android/src/lib.rs new file mode 100644 index 0000000..d45f698 --- /dev/null +++ b/native/jmbackend/platforms/android/src/lib.rs @@ -0,0 +1,24 @@ +pub use jmbackend::*; +use jni::objects::JClass; +use jni::objects::JString; +use jni::JNIEnv; + +#[no_mangle] +pub unsafe extern "system" fn Java_opensource_jenny_Jni_init<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + params: JString<'local>, +) { + let params: String = env.get_string(¶ms).unwrap().into(); + init_sync(params.as_str()); +} + +#[no_mangle] +pub unsafe extern "C" fn Java_opensource_jenny_Jni_invoke<'local>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + params: JString<'local>, +) -> JString<'local> { + let params: String = env.get_string(¶ms).unwrap().into(); + env.new_string(invoke(params.as_str())).unwrap() +} diff --git a/native/jmbackend/platforms/ios-sim/Cargo.toml b/native/jmbackend/platforms/ios-sim/Cargo.toml new file mode 100644 index 0000000..452c3c2 --- /dev/null +++ b/native/jmbackend/platforms/ios-sim/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } + +[lib] +crate-type = ["staticlib"] diff --git a/native/jmbackend/platforms/ios-sim/src/lib.rs b/native/jmbackend/platforms/ios-sim/src/lib.rs new file mode 100644 index 0000000..a70214d --- /dev/null +++ b/native/jmbackend/platforms/ios-sim/src/lib.rs @@ -0,0 +1 @@ +pub use jmbackend::*; diff --git a/native/jmbackend/platforms/ios/Cargo.toml b/native/jmbackend/platforms/ios/Cargo.toml new file mode 100644 index 0000000..452c3c2 --- /dev/null +++ b/native/jmbackend/platforms/ios/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } + +[lib] +crate-type = ["staticlib"] diff --git a/native/jmbackend/platforms/ios/src/lib.rs b/native/jmbackend/platforms/ios/src/lib.rs new file mode 100644 index 0000000..a70214d --- /dev/null +++ b/native/jmbackend/platforms/ios/src/lib.rs @@ -0,0 +1 @@ +pub use jmbackend::*; diff --git a/native/jmbackend/platforms/linux/Cargo.toml b/native/jmbackend/platforms/linux/Cargo.toml new file mode 100644 index 0000000..f5b4c9b --- /dev/null +++ b/native/jmbackend/platforms/linux/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } +lazy_static = "1.4.0" +reqwest = { version = "0.11.9", features = ["rustls", "tokio-rustls", "rustls-tls"], default-features = false } +serde = { version = "1.0.152", features = ["derive"] } +serde_derive = "1.0.152" +serde_json = "1.0.93" +warp = "0.3.2" +tokio = { version = "1.26.0", features = ["full"] } + +[lib] +crate-type = ["staticlib"] diff --git a/native/jmbackend/platforms/linux/src/lib.rs b/native/jmbackend/platforms/linux/src/lib.rs new file mode 100644 index 0000000..fe65ffd --- /dev/null +++ b/native/jmbackend/platforms/linux/src/lib.rs @@ -0,0 +1,53 @@ +use std::process::exit; +use std::time::Duration; + +use serde_json::to_string; +use warp::Filter; + +pub use jmbackend::*; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct DartQuery { + pub method: String, + pub params: String, +} + +#[no_mangle] +pub unsafe extern "C" fn init_http_server() { + let ping = warp::get() + .and(warp::path("ping")) + .map(|| "pong".to_owned()); + let invoke = warp::post() + .and(warp::path("invoke")) + .and(warp::body::json()) + .map(invoke); + std::thread::spawn(move || { + jmbackend::RUNTIME.block_on(warp::serve(ping.or(invoke)).run(([127, 0, 0, 1], 52764))) + }); + jmbackend::RUNTIME.block_on(test_startup()); +} + +fn invoke(dq: DartQuery) -> String { + jmbackend::RUNTIME.block_on(jmbackend::invoke_async(to_string(&dq).unwrap().as_str())) +} + +async fn test_startup() { + for i in 0..6 { + if i == 5 { + exit(2); + } + std::thread::sleep(Duration::new(1, 0)); + match reqwest::get("http://127.0.0.1:52764/ping").await { + Ok(req) => match req.text().await { + Ok(txt) => { + println!("OK : {}", txt); + break; + } + Err(err) => println!("ERR : {}", err), + }, + Err(err) => { + println!("ERR : {}", err) + } + } + } +} diff --git a/native/jmbackend/platforms/macos/Cargo.toml b/native/jmbackend/platforms/macos/Cargo.toml new file mode 100644 index 0000000..452c3c2 --- /dev/null +++ b/native/jmbackend/platforms/macos/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } + +[lib] +crate-type = ["staticlib"] diff --git a/native/jmbackend/platforms/macos/src/lib.rs b/native/jmbackend/platforms/macos/src/lib.rs new file mode 100644 index 0000000..a70214d --- /dev/null +++ b/native/jmbackend/platforms/macos/src/lib.rs @@ -0,0 +1 @@ +pub use jmbackend::*; diff --git a/native/jmbackend/platforms/windows/Cargo.toml b/native/jmbackend/platforms/windows/Cargo.toml new file mode 100644 index 0000000..452c3c2 --- /dev/null +++ b/native/jmbackend/platforms/windows/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +jmbackend = { path = "../../" } + +[lib] +crate-type = ["staticlib"] diff --git a/native/jmbackend/platforms/windows/src/lib.rs b/native/jmbackend/platforms/windows/src/lib.rs new file mode 100644 index 0000000..a70214d --- /dev/null +++ b/native/jmbackend/platforms/windows/src/lib.rs @@ -0,0 +1 @@ +pub use jmbackend::*; diff --git a/native/jmbackend/src/database/active_db/dl_album.rs b/native/jmbackend/src/database/active_db/dl_album.rs new file mode 100644 index 0000000..fd19d3e --- /dev/null +++ b/native/jmbackend/src/database/active_db/dl_album.rs @@ -0,0 +1,141 @@ +use crate::database::utils::create_table_if_not_exists; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::ColumnTrait; +use sea_orm::ConnectionTrait; +use sea_orm::DatabaseTransaction; +use sea_orm::DeleteResult; +use sea_orm::EntityTrait; +use sea_orm::QueryFilter; +use sea_orm::QuerySelect; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "dl_album")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i64, + pub name: String, + /// JSON(Vec) + pub author: String, + /// JSON(Vec) + pub tags: String, + /// JSON(Vec) + pub works: String, + pub description: String, + /// 方形状的封面下载状态 + /// 0:未下载, 1:下载成功 2:下载失败 + pub dl_square_cover_status: i32, + /// 3x4的封面下载状态 + /// 0:未下载, 1:下载成功 2:下载失败 + pub dl_3x4_cover_status: i32, + /// chapter(所有章节的下载状态) + /// 0:未下载, 1:全部下载成功 2:任何一个下载失败 3:删除中 + pub dl_status: i32, + /// 图片总数 + pub image_count: i32, + /// 下载了的图片总数 + pub dled_image_count: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; +} + +pub(crate) async fn load_first_need_download_album(db: &DatabaseConnection) -> Option { + Entity::find() + .filter(Column::DlStatus.eq(0)) + .limit(1) + .one(db) + .await + .unwrap() +} + +pub(crate) async fn load_first_need_delete_album(db: &DatabaseConnection) -> Option { + Entity::find() + .filter(Column::DlStatus.eq(3)) + .limit(1) + .one(db) + .await + .unwrap() +} + +pub(crate) async fn inc_image_count(db: &DatabaseTransaction, id: i64, count: i32) { + Entity::update_many() + .col_expr(Column::ImageCount, Expr::col(Column::ImageCount).add(count)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn set_dl_status(db: &impl ConnectionTrait, id: i64, status: i32) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(status)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn renew_failed(db: &impl ConnectionTrait) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(0)) + .filter(Column::DlStatus.eq(2)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn set_3x4_cover_status(db: &impl ConnectionTrait, id: i64, status: i32) { + Entity::update_many() + .col_expr(Column::Dl3x4CoverStatus, Expr::value(status)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn set_square_cover_status(db: &impl ConnectionTrait, id: i64, status: i32) { + Entity::update_many() + .col_expr(Column::DlSquareCoverStatus, Expr::value(status)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn find_by_id(db: &impl ConnectionTrait, id: i64) -> Option { + Entity::find_by_id(id).one(db).await.unwrap() +} + +pub(crate) async fn all(db: &impl ConnectionTrait) -> Vec { + Entity::find().all(db).await.unwrap() +} + +pub(crate) async fn download_one_image(db: &impl ConnectionTrait, id: i64) { + Entity::update_many() + .col_expr( + Column::DledImageCount, + Expr::col(Column::DledImageCount).add(1), + ) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn delete_by_album_id( + db: &impl ConnectionTrait, + album_id: i64, +) -> Result { + Entity::delete_many() + .filter(Column::Id.eq(album_id)) + .exec(db) + .await +} diff --git a/native/jmbackend/src/database/active_db/dl_chapter.rs b/native/jmbackend/src/database/active_db/dl_chapter.rs new file mode 100644 index 0000000..3be9f3b --- /dev/null +++ b/native/jmbackend/src/database/active_db/dl_chapter.rs @@ -0,0 +1,124 @@ +use super::dl_album; +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::ConnectionTrait; +use sea_orm::DeleteResult; +use sea_orm::EntityTrait; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "dl_chapter")] +pub struct Model { + pub album_id: i64, + #[sea_orm(primary_key, auto_increment = false)] + pub id: i64, + pub name: String, + pub sort: String, + /// 0:未加载图片 1:已加载图片 + pub load_images: i32, + /// 图片总数 + pub image_count: i32, + /// 下载了的图片总数 + pub dled_image_count: i32, + /// image(图片的下载状态) + /// 0:未下载, 1:全部下载成功 2:任何一个下载失败 + // "JM_PAGE_IMAGE:{}:{}" + pub dl_status: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, Entity {}.table_name(), "idx_album_id").await { + create_index(db, Entity {}.table_name(), vec!["album_id"], "idx_album_id").await; + } +} + +pub(crate) async fn load_all_need_download_chapter( + db: &impl ConnectionTrait, + album: &dl_album::Model, +) -> Vec { + Entity::find() + .filter(Column::AlbumId.eq(album.id)) + .filter(Column::DlStatus.eq(0)) + .all(db) + .await + .unwrap() +} + +pub(crate) async fn set_dl_status(db: &impl ConnectionTrait, id: i64, status: i32) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(status)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn renew_failed(db: &impl ConnectionTrait) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(0)) + .filter(Column::DlStatus.eq(2)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn save_image_count(db: &impl ConnectionTrait, id: i64, count: i32) { + Entity::update_many() + .col_expr(Column::LoadImages, Expr::value(1)) + .col_expr(Column::ImageCount, Expr::value(count)) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn has_not_success_chapter(db: &impl ConnectionTrait, album_id: i64) -> bool { + Entity::find() + .filter(Column::AlbumId.eq(album_id)) + .filter(Column::DlStatus.ne(1)) + .count(db) + .await + .unwrap() + > 0 +} + +pub(crate) async fn find_by_id(db: &impl ConnectionTrait, id: i64) -> Option { + Entity::find_by_id(id).one(db).await.unwrap() +} + +pub(crate) async fn list_by_album_id(db: &impl ConnectionTrait, album_id: i64) -> Vec { + Entity::find() + .filter(Column::AlbumId.eq(album_id)) + .all(db) + .await + .unwrap() +} + +pub(crate) async fn download_one_image(db: &impl ConnectionTrait, id: i64) { + Entity::update_many() + .col_expr( + Column::DledImageCount, + Expr::col(Column::DledImageCount).add(1), + ) + .filter(Column::Id.eq(id)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn delete_by_album_id( + db: &impl ConnectionTrait, + album_id: i64, +) -> Result { + Entity::delete_many() + .filter(Column::AlbumId.eq(album_id)) + .exec(db) + .await +} diff --git a/native/jmbackend/src/database/active_db/dl_image.rs b/native/jmbackend/src/database/active_db/dl_image.rs new file mode 100644 index 0000000..f9b6a12 --- /dev/null +++ b/native/jmbackend/src/database/active_db/dl_image.rs @@ -0,0 +1,150 @@ +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::ConnectionTrait; +use sea_orm::DeleteResult; +use sea_orm::EntityTrait; +use sea_orm::QueryOrder; +use serde::{Deserialize, Serialize}; + +use crate::database::utils::{ + create_index, create_index_a, create_table_if_not_exists, index_exists, +}; + +use super::dl_chapter; + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "dl_image")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub album_id: i64, + #[sea_orm(primary_key, auto_increment = false)] + pub chapter_id: i64, + #[sea_orm(primary_key, auto_increment = false)] + pub image_index: i64, + // index + pub name: String, + // 说明 + // 如果album只有一个chapter的话 pub series: Vec 为空 + // 如果有多个章节的话, 第一个chapter的id与album一样 + // "JM_PAGE_IMAGE:{}:{}" , chapter_id, name + pub key: String, + /// (下载状态) + /// 0:未下载, 1:下载成功 2:下载失败 + pub dl_status: i32, + /// size + pub width: u32, + pub height: u32, +} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, Entity {}.table_name(), "uk_chapter_id_image_index").await { + // CREATE UNIQUE INDEX uk_chapter_id_image_index ON dl_image(chapter_id,image_index); + create_index_a( + db, + Entity {}.table_name(), + vec!["chapter_id", "image_index"], + "uk_chapter_id_image_index", + true, + ) + .await; + } + if !index_exists(db, Entity {}.table_name(), "idx_name").await { + create_index(db, Entity {}.table_name(), vec!["name"], "idx_name").await; + } + if !index_exists(db, Entity {}.table_name(), "idx_key").await { + create_index(db, Entity {}.table_name(), vec!["key"], "idx_key").await; + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn load_all_need_download_image( + db: &impl ConnectionTrait, + chapter: &dl_chapter::Model, +) -> Vec { + Entity::find() + .filter(Column::ChapterId.eq(chapter.id)) + .filter(Column::DlStatus.eq(0)) + .all(db) + .await + .unwrap() +} + +pub(crate) async fn set_dl_status( + db: &impl ConnectionTrait, + chapter_id: i64, + image_index: i64, + status: i32, + width: i32, + height: i32, +) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(status)) + .col_expr(Column::Width, Expr::value(width)) + .col_expr(Column::Height, Expr::value(height)) + .filter(Column::ChapterId.eq(chapter_id)) + .filter(Column::ImageIndex.eq(image_index)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn renew_failed(db: &impl ConnectionTrait) { + Entity::update_many() + .col_expr(Column::DlStatus, Expr::value(0)) + .filter(Column::DlStatus.eq(2)) + .exec(db) + .await + .unwrap(); +} + +pub(crate) async fn has_not_success_images(db: &impl ConnectionTrait, chapter_id: i64) -> bool { + Entity::find() + .filter(Column::ChapterId.eq(chapter_id)) + .filter(Column::DlStatus.ne(1)) + .count(db) + .await + .unwrap() + > 0 +} + +pub(crate) async fn find_by_key(db: &impl ConnectionTrait, key: &str) -> Option { + Entity::find() + .filter(Column::Key.eq(key)) + .one(db) + .await + .unwrap() +} + +pub(crate) async fn find_by_chapter_id(db: &impl ConnectionTrait, chapter_id: i64) -> Vec { + Entity::find() + .filter(Column::ChapterId.eq(chapter_id)) + .order_by_asc(Column::ImageIndex) + .all(db) + .await + .unwrap() +} + +pub(crate) async fn delete_by_album_id( + db: &impl ConnectionTrait, + album_id: i64, +) -> Result { + Entity::delete_many() + .filter(Column::AlbumId.eq(album_id)) + .exec(db) + .await +} + +pub(crate) async fn lisst_by_album_id( + db: &impl ConnectionTrait, + album_id: i64, +) -> Result, DbErr> { + Ok(Entity::find() + .filter(Column::AlbumId.eq(album_id)) + .all(db) + .await?) +} diff --git a/native/jmbackend/src/database/active_db/mod.rs b/native/jmbackend/src/database/active_db/mod.rs new file mode 100644 index 0000000..d4547f2 --- /dev/null +++ b/native/jmbackend/src/database/active_db/mod.rs @@ -0,0 +1,243 @@ +use crate::database::utils::connect_db; +use crate::tools::join_paths; +use crate::{is_pro, Result, NO_PRO_MAX}; +use anyhow::anyhow; +use jmcomic::ComicSimple; +use jmcomic::SearchPage; +use once_cell::sync::OnceCell; +use sea_orm::sea_query::Expr; +use sea_orm::ActiveModelTrait; +use sea_orm::ColumnTrait; +use sea_orm::ConnectionTrait; +use sea_orm::DatabaseConnection; +use sea_orm::DbErr; +use sea_orm::EntityName; +use sea_orm::EntityTrait; +use sea_orm::QueryFilter; +use sea_orm::QueryOrder; +use sea_orm::QuerySelect; +use sea_orm::Set; +use sea_orm::Statement; +use sea_orm::TransactionTrait; +use std::ops::Deref; +use tokio::sync::Mutex; + +pub(crate) mod dl_album; +pub(crate) mod dl_chapter; +pub(crate) mod dl_image; +pub(crate) mod search_history; +pub(crate) mod view_log; +pub(crate) mod view_log_tag; + +pub(crate) static ACTIVE_DB: OnceCell> = OnceCell::new(); + +pub(crate) async fn init_db() { + let path = join_paths(vec![crate::FOLDER.lock().await.deref(), "active.db"]); + let db = connect_db(&path).await; + view_log::init(&db).await; + view_log_tag::init(&db).await; + search_history::init(&db).await; + dl_album::init(&db).await; + dl_chapter::init(&db).await; + dl_image::init(&db).await; + ACTIVE_DB.set(Mutex::new(db)).expect("INIT ACTIVE DB DUP"); +} + +pub(crate) async fn last_view_album(album: jmcomic::ComicAlbumResponse) -> Result<()> { + let db = ACTIVE_DB.get().unwrap().lock().await; + db.transaction::<_, (), sea_orm::DbErr>(|txn| { + Box::pin(async move { + let in_db_view_log: Option = + view_log::Entity::find_by_id(album.id.clone()) + .one(txn) + .await?; + match in_db_view_log { + Some(in_db_view_log) => { + let mut in_db_view_log: view_log::ActiveModel = in_db_view_log.into(); + in_db_view_log.last_view_time = Set(chrono::Local::now().timestamp()); + in_db_view_log.update(txn).await?; + } + None => { + view_log::ActiveModel { + id: Set(album.id), + author: Set(album.author.join(",")), + description: Set(album.description), + name: Set(album.name), + last_view_time: Set(chrono::Local::now().timestamp()), + last_view_chapter_id: Set(0), + last_view_page: Set(0), + ..Default::default() + } + .insert(txn) + .await?; + let mut tags: Vec = vec![]; + for tag_name in album.tags.clone() { + if !tags.contains(&tag_name) { + tags.push(tag_name); + } + } + for tag_name in tags { + view_log_tag::ActiveModel { + id: Set(album.id), + tag_name: Set(tag_name), + ..Default::default() + } + .insert(txn) + .await?; + } + } + }; + Ok(()) + }) + }) + .await?; + Ok(()) +} + +pub(crate) async fn update_view_log(query: crate::types::UpdateViewLogQuery) -> Result { + view_log::Entity::update_many() + .col_expr( + view_log::Column::LastViewTime, + Expr::value(chrono::Local::now().timestamp()), + ) + .col_expr( + view_log::Column::LastViewChapterId, + Expr::value(query.last_view_chapter_id), + ) + .col_expr( + view_log::Column::LastViewPage, + Expr::value(query.last_view_page), + ) + .filter(view_log::Column::Id.eq(query.id)) + .exec(ACTIVE_DB.get().unwrap().lock().await.deref()) + .await?; + Ok(String::default()) +} + +pub(crate) async fn find_view_log(id: i64) -> Result> { + Ok(view_log::Entity::find_by_id(id) + .one(ACTIVE_DB.get().unwrap().lock().await.deref()) + .await?) +} + +pub(crate) async fn page_view_log(page_number: i64) -> Result> { + if !is_pro().await?.is_pro && page_number > NO_PRO_MAX { + return Err(anyhow!("需要发电鸭")); + } + let active_db = ACTIVE_DB.get().unwrap().lock().await; + let stmt = Statement::from_string( + active_db.get_database_backend(), + format!( + "SELECT COUNT(*) AS c FROM {};", + view_log::Entity {}.table_name(), + ), + ); + let rsp = active_db.query_one(stmt).await?.unwrap(); + let total: i32 = rsp.try_get("", "c")?; + let page_size = 20; + let list: Vec = view_log::Entity::find() + .order_by_desc(view_log::Column::LastViewTime) + .offset(Some(((page_number - 1) * page_size).try_into()?)) + .limit(Some(page_size.try_into()?)) + .all(active_db.deref()) + .await?; + let vec = list + .iter() + .map(|model| ComicSimple { + id: model.id, + author: model.author.clone(), + description: model.description.clone(), + name: model.name.clone(), + image: "".to_string(), + category: Default::default(), + category_sub: Default::default(), + }) + .collect::>(); + Ok(SearchPage { + search_query: "".to_string(), + total: total.try_into()?, + content: vec, + redirect_aid: None, + }) +} + +pub(crate) async fn clear_view_log() -> Result { + let db = ACTIVE_DB.get().unwrap().lock().await; + db.transaction::<_, (), sea_orm::DbErr>(|txn| { + Box::pin(async move { + view_log::Entity::delete_many().exec(txn).await?; + view_log_tag::Entity::delete_many().exec(txn).await?; + Ok(()) + }) + }) + .await?; + let stmt = Statement::from_string(db.get_database_backend(), "VACUUM;".to_owned()); + db.query_one(stmt).await?; + Ok(String::default()) +} + +pub(crate) async fn db_clear_all_search_log() -> Result<()> { + let db = ACTIVE_DB.get().unwrap().lock().await; + search_history::Entity::delete_many() + .exec(db.deref()) + .await?; + Ok(()) +} + +pub(crate) async fn db_clear_a_search_log(content: String) -> Result<()> { + let db = ACTIVE_DB.get().unwrap().lock().await; + search_history::Entity::delete_many() + .filter(search_history::Column::SearchQuery.eq(content)) + .exec(db.deref()) + .await?; + Ok(()) +} + +pub(crate) async fn save_search_history(search_query: String) -> Result<()> { + let db = ACTIVE_DB.get().unwrap().lock().await; + let in_db = search_history::Entity::find_by_id(search_query.clone()) + .one(db.deref()) + .await?; + match in_db { + Some(in_db) => { + let mut data: search_history::ActiveModel = in_db.into(); + data.last_search_time = Set(chrono::Local::now().timestamp()); + data.update(db.deref()).await?; + } + None => { + let insert = search_history::ActiveModel { + search_query: Set(search_query), + last_search_time: Set(chrono::Local::now().timestamp()), + ..Default::default() + }; + insert.insert(db.deref()).await?; + } + }; + drop(db); + Ok(()) +} + +pub(crate) async fn load_last_search_histories(limit: i64) -> Result> { + let db = ACTIVE_DB.get().unwrap().lock().await; + let list: Vec = search_history::Entity::find() + .order_by_desc(search_history::Column::LastSearchTime) + .offset(0) + .limit(Some(limit.try_into()?)) + .all(db.deref()) + .await?; + Ok(list) +} + +pub(crate) async fn clear_download_album(id: i64) { + let db = ACTIVE_DB.get().unwrap().lock().await; + db.transaction::<_, (), DbErr>(|db| { + Box::pin(async move { + dl_image::delete_by_album_id(db, id.clone()).await?; + dl_chapter::delete_by_album_id(db, id.clone()).await?; + dl_album::delete_by_album_id(db, id.clone()).await?; + Ok(()) + }) + }) + .await + .unwrap(); +} diff --git a/native/jmbackend/src/database/active_db/search_history.rs b/native/jmbackend/src/database/active_db/search_history.rs new file mode 100644 index 0000000..6492577 --- /dev/null +++ b/native/jmbackend/src/database/active_db/search_history.rs @@ -0,0 +1,30 @@ +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "search_history")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub search_query: String, + pub last_search_time: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, "search_history", "idx_last_search_time").await { + create_index( + db, + "search_history", + vec!["last_search_time"], + "idx_last_search_time", + ) + .await; + } +} diff --git a/native/jmbackend/src/database/active_db/view_log.rs b/native/jmbackend/src/database/active_db/view_log.rs new file mode 100644 index 0000000..a37d98b --- /dev/null +++ b/native/jmbackend/src/database/active_db/view_log.rs @@ -0,0 +1,33 @@ +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "view_log")] +pub struct Model { + // 原漫画id + #[sea_orm(primary_key, auto_increment = false)] + pub id: i64, + pub author: String, + pub description: String, + pub name: String, + // 最后阅读或查看详情的时间 + pub last_view_time: i64, + // 0 为没阅读过漫画 + pub last_view_chapter_id: i64, + // 最后阅读过了第几页 + pub last_view_page: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, "view_log", "idx_last_view_time").await { + create_index(db, "view_log", vec!["last_view_time"], "idx_last_view_time").await; + } +} diff --git a/native/jmbackend/src/database/active_db/view_log_tag.rs b/native/jmbackend/src/database/active_db/view_log_tag.rs new file mode 100644 index 0000000..5019e93 --- /dev/null +++ b/native/jmbackend/src/database/active_db/view_log_tag.rs @@ -0,0 +1,23 @@ +use crate::database::utils::create_table_if_not_exists; +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "view_log_tag")] +pub struct Model { + // 原漫画id + #[sea_orm(primary_key)] + pub id: i64, + #[sea_orm(primary_key)] + pub tag_name: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; +} diff --git a/native/jmbackend/src/database/image_cache_db/image_cache.rs b/native/jmbackend/src/database/image_cache_db/image_cache.rs new file mode 100644 index 0000000..71e08f0 --- /dev/null +++ b/native/jmbackend/src/database/image_cache_db/image_cache.rs @@ -0,0 +1,27 @@ +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "image_cache")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub cache_key: String, + pub cache_path: String, + pub cache_time: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +// CREATE INDEX idx_cache_time ON image_cache(cache_time); +// select * from sqlite_master where type='index' AND tbl_name='image_cache' AND name='idx_cache_time'; + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, "image_cache", "idx_cache_time").await { + create_index(db, "image_cache", vec!["cache_time"], "idx_cache_time").await; + } +} diff --git a/native/jmbackend/src/database/image_cache_db/mod.rs b/native/jmbackend/src/database/image_cache_db/mod.rs new file mode 100644 index 0000000..d6a69f4 --- /dev/null +++ b/native/jmbackend/src/database/image_cache_db/mod.rs @@ -0,0 +1,117 @@ +use crate::database::utils::connect_db; +use crate::tools::join_paths; +use crate::Result; +use once_cell::sync::OnceCell; +use sea_orm::ActiveModelTrait; +use sea_orm::DatabaseConnection; +use sea_orm::EntityTrait; +use sea_orm::Set; +use std::future::Future; +use std::ops::Deref; +use tokio::sync::Mutex; +pub(crate) mod image_cache; +use crate::take_hash_lock; +use bytes::Bytes; +use sea_orm::ColumnTrait; +use sea_orm::QueryFilter; +use sea_orm::QuerySelect; +use std::pin::Pin; + +static IMAGE_CACHE_DB: OnceCell> = OnceCell::new(); + +pub(crate) async fn init_db() { + let path = join_paths(vec![crate::FOLDER.lock().await.deref(), "image_cache.db"]); + let db = connect_db(&path).await; + image_cache::init(&db).await; + IMAGE_CACHE_DB + .set(Mutex::new(db)) + .expect("INIT ACTIVE DB DUP"); +} + +static IMAGE_CACHE_FOLDER: OnceCell = OnceCell::new(); + +pub(crate) async fn init_dir() { + let dir = join_paths(vec![crate::FOLDER.lock().await.deref(), "image_cache"]); + tokio::fs::create_dir_all(dir.clone()).await.unwrap(); + IMAGE_CACHE_FOLDER.set(dir).expect("INIT ACTIVE DB DUP"); +} + +pub(crate) async fn use_image_cache(key: String, f: Pin>) -> Result +where + F: Future>, +{ + // 哈希锁 + let lock = take_hash_lock(key.clone()).await; + // 查找图片是否有缓存 + let db = IMAGE_CACHE_DB.get().unwrap().lock().await; + let db_image: Option = image_cache::Entity::find_by_id(key.clone()) + .one(db.deref()) + .await?; + drop(db); + let path = match db_image { + // 有缓存直接使用 + Some(db_image) => db_image.cache_path, + // 没有缓存则下载 + None => { + let data: (Bytes, u32, u32) = f.await?; + let now = chrono::Local::now().timestamp(); + let path = format!( + "{}{}", + hex::encode(md5::compute(key.clone()).to_vec()), + &now, + ); + let cache_folder = IMAGE_CACHE_FOLDER.get().unwrap(); + let local = join_paths(vec![&cache_folder, &path.clone()]); + // drop(cache_folder); + std::fs::write(local, data.0)?; + let insert = image_cache::ActiveModel { + cache_key: Set(key.clone()), + cache_path: Set(path.clone()), + cache_time: Set(now.clone()), + ..Default::default() + }; + let db = IMAGE_CACHE_DB.get().unwrap().lock().await; + insert.insert(db.deref()).await?; + drop(db); + path + } + }; + let cache_folder = IMAGE_CACHE_FOLDER.get().unwrap(); + let local = join_paths(vec![&cache_folder, &path]); + // drop(cache_folder); + drop(lock); + Ok(local) +} + +pub(crate) async fn clean_all_image_cache() -> Result { + clean_image_cache_by_time(chrono::Local::now().timestamp()).await +} + +pub(crate) async fn clean_image_cache_by_time(time: i64) -> Result { + let cache_folder = IMAGE_CACHE_FOLDER.get().unwrap(); + let dir = cache_folder.clone(); + // drop(cache_folder); + let db = IMAGE_CACHE_DB.get().unwrap().lock().await; + loop { + let caches: Vec = image_cache::Entity::find() + .filter(image_cache::Column::CacheTime.lt(time)) + .limit(100) + .all(db.deref()) + .await?; + if caches.is_empty() { + break; + } + for cache in caches { + let local = join_paths(vec![ + dir.clone().as_str(), + cache.cache_path.clone().as_str(), + ]); + image_cache::Entity::delete_many() + .filter(image_cache::Column::CacheKey.eq(cache.cache_key)) + .exec(db.deref()) + .await?; // 不管有几条被作用 + let _ = std::fs::remove_file(local); // 不管成功与否 + } + } + Ok(String::default()) +} diff --git a/native/jmbackend/src/database/mod.rs b/native/jmbackend/src/database/mod.rs new file mode 100644 index 0000000..109df70 --- /dev/null +++ b/native/jmbackend/src/database/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod active_db; +pub(crate) mod image_cache_db; +pub(crate) mod property_db; +pub(crate) mod utils; +pub(crate) mod web_cache_db; diff --git a/native/jmbackend/src/database/property_db/migrations.rs b/native/jmbackend/src/database/property_db/migrations.rs new file mode 100644 index 0000000..b30bb79 --- /dev/null +++ b/native/jmbackend/src/database/property_db/migrations.rs @@ -0,0 +1,32 @@ +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(M20220305V100CleanCookie)] + } +} + +pub struct M20220305V100CleanCookie; + +impl MigrationName for M20220305V100CleanCookie { + fn name(&self) -> &str { + "M20220305V100CleanCookie" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for M20220305V100CleanCookie { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = "DELETE FROM property WHERE k = 'cookie'"; + let stmt = Statement::from_string(manager.get_database_backend(), sql.to_owned()); + manager.get_connection().execute(stmt).await.map(|_| ()) + } + + async fn down(&self, _: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/native/jmbackend/src/database/property_db/mod.rs b/native/jmbackend/src/database/property_db/mod.rs new file mode 100644 index 0000000..de96475 --- /dev/null +++ b/native/jmbackend/src/database/property_db/mod.rs @@ -0,0 +1,75 @@ +use std::ops::Deref; + +use once_cell::sync::OnceCell; +use sea_orm::ActiveModelTrait; +use sea_orm::DatabaseConnection; +use sea_orm::EntityTrait; +use sea_orm::Set; +use sea_orm_migration::MigratorTrait; +use tokio::sync::Mutex; + +use crate::database::utils::connect_db; +use crate::tools::join_paths; +use crate::Result; + +pub(crate) mod property; + +pub(crate) mod migrations; + +static PROPERTY_DB: OnceCell> = OnceCell::new(); + +pub(crate) async fn init_db() { + let path = join_paths(vec![crate::FOLDER.lock().await.deref(), "property.db"]); + let db = connect_db(&path).await; + property::init(&db).await; + migrations::Migrator::up(&db, None).await.unwrap(); + PROPERTY_DB.set(Mutex::new(db)).expect("INIT ACTIVE DB DUP"); +} + +pub(crate) async fn save_property(k: String, v: String) -> Result { + let db = PROPERTY_DB.get().unwrap().lock().await; + let in_db = property::Entity::find_by_id(k.clone()) + .one(db.deref()) + .await?; + match in_db { + Some(in_db) => { + let mut data: property::ActiveModel = in_db.into(); + data.k = Set(k.clone()); + data.v = Set(v.clone()); + data.update(db.deref()).await?; + } + None => { + let insert = property::ActiveModel { + k: Set(k.clone()), + v: Set(v.clone()), + ..Default::default() + }; + insert.insert(db.deref()).await?; + } + }; + drop(db); + Ok("".to_string()) +} + +pub(crate) async fn load_property(k: String) -> Result { + let db = PROPERTY_DB.get().unwrap().lock().await; + let in_db = property::Entity::find_by_id(k.clone()) + .one(db.deref()) + .await?; + let v = match in_db { + Some(in_db) => in_db.v, + None => String::default(), + }; + drop(db); + Ok(v) +} + +pub(crate) async fn load_int_property(k: String, default: i64) -> i64 { + match load_property(k).await { + Ok(p) => match p.parse::() { + Ok(data) => data, + Err(_) => default, + }, + Err(_) => default, + } +} diff --git a/native/jmbackend/src/database/property_db/property.rs b/native/jmbackend/src/database/property_db/property.rs new file mode 100644 index 0000000..cc3413d --- /dev/null +++ b/native/jmbackend/src/database/property_db/property.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; + +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "property")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub k: String, + pub v: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(&db, Entity).await; + if !index_exists(db, "property", "idx_k").await { + create_index(db, "property", vec!["k"], "idx_k").await; + } +} diff --git a/native/jmbackend/src/database/utils.rs b/native/jmbackend/src/database/utils.rs new file mode 100644 index 0000000..aa582d2 --- /dev/null +++ b/native/jmbackend/src/database/utils.rs @@ -0,0 +1,101 @@ +use std::time::Duration; + +use sea_orm::ConnectionTrait; +use sea_orm::DatabaseConnection; +use sea_orm::EntityTrait; +use sea_orm::Schema; +use sea_orm::Statement; + +pub(crate) async fn connect_db(path: &str) -> DatabaseConnection { + let url = format!("sqlite:{}?mode=rwc", path); + let mut opt = sea_orm::ConnectOptions::new(url); + opt.max_connections(20) + .min_connections(5) + .connect_timeout(Duration::from_secs(8)) + .idle_timeout(Duration::from_secs(8)) + .sqlx_logging(true); + sea_orm::Database::connect(opt).await.unwrap() +} + +pub(crate) async fn create_table_if_not_exists(db: &DatabaseConnection, entity: E) +where + E: EntityTrait, +{ + if !has_table(db, entity.table_name()).await { + create_table(db, entity).await; + }; +} + +pub(crate) async fn has_table(db: &DatabaseConnection, table_name: &str) -> bool { + let stmt = Statement::from_string( + db.get_database_backend(), + format!( + "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='{}';", + table_name, + ), + ); + let rsp = db.query_one(stmt).await.unwrap().unwrap(); + let count: i32 = rsp.try_get("", "c").unwrap(); + count > 0 +} + +pub(crate) async fn create_table(db: &DatabaseConnection, entity: E) +where + E: EntityTrait, +{ + let builder = db.get_database_backend(); + let schema = Schema::new(builder); + let stmt = &schema.create_table_from_entity(entity); + let stmt = builder.build(stmt); + db.execute(stmt).await.unwrap(); +} + +pub(crate) async fn index_exists( + db: &DatabaseConnection, + table_name: &str, + index_name: &str, +) -> bool { + let stmt = Statement::from_string( + db.get_database_backend(), + format!( + "select COUNT(*) AS c from sqlite_master where type='index' AND tbl_name='{}' AND name='{}';", + table_name, index_name, + ), + ); + db.query_one(stmt) + .await + .unwrap() + .unwrap() + .try_get::("", "c") + .unwrap() + > 0 +} + +pub(crate) async fn create_index_a( + db: &DatabaseConnection, + table_name: &str, + columns: Vec<&str>, + index_name: &str, + uk: bool, +) { + let stmt = Statement::from_string( + db.get_database_backend(), + format!( + "CREATE {} INDEX {} ON {}({});", + if uk { "UNIQUE" } else { "" }, + index_name, + table_name, + columns.join(","), + ), + ); + db.execute(stmt).await.unwrap(); +} + +pub(crate) async fn create_index( + db: &DatabaseConnection, + table_name: &str, + columns: Vec<&str>, + index_name: &str, +) { + create_index_a(db, table_name, columns, index_name, false).await +} diff --git a/native/jmbackend/src/database/web_cache_db/mod.rs b/native/jmbackend/src/database/web_cache_db/mod.rs new file mode 100644 index 0000000..a83d713 --- /dev/null +++ b/native/jmbackend/src/database/web_cache_db/mod.rs @@ -0,0 +1,107 @@ +use crate::database::utils::connect_db; +use crate::tools::join_paths; +use crate::{check_first, Result}; +use once_cell::sync::OnceCell; +use sea_orm::ActiveModelTrait; +use sea_orm::DatabaseConnection; +use sea_orm::EntityTrait; +use sea_orm::Set; +use std::future::Future; +use std::ops::Deref; +use std::time::Duration; +use tokio::sync::Mutex; +pub(crate) mod web_cache; +use crate::take_hash_lock; +use sea_orm::ColumnTrait; +use sea_orm::QueryFilter; + +static WEB_CACHE_DB: OnceCell> = OnceCell::new(); + +pub(crate) async fn init_db() { + let path = join_paths(vec![crate::FOLDER.lock().await.deref(), "web_cache.db"]); + let db = connect_db(&path).await; + web_cache::init(&db).await; + WEB_CACHE_DB + .set(Mutex::new(db)) + .expect("INIT ACTIVE DB DUP"); +} + +pub(crate) async fn use_web_cache( + key: String, + expire: Duration, + reload: impl FnOnce() -> Fut, +) -> Result +where + Fut: Future>, +{ + check_first().await?; + // 时间 + let now = chrono::Local::now().timestamp(); + let earliest = now - (expire.as_secs() as i64); + // 哈希锁 + let lock = take_hash_lock(key.clone()).await; + // 读取数据库 + let db = WEB_CACHE_DB.get().unwrap().lock().await; + let cache = web_cache::Entity::find() + .filter(web_cache::Column::CacheKey.eq(key.clone())) + // 如果框架支持upert的话 + // .filter(web_cache::Column::CacheTime.gt(&now.clone())) + .one(db.deref()) + .await?; + drop(db); + if cache.is_some() { + let cache = cache.clone().unwrap(); + if cache.cache_time > earliest { + return Ok(cache.cache_content); + } + } + let load = reload().await?; + match cache { + Some(cache) => { + let mut data: web_cache::ActiveModel = cache.into(); + data.cache_content = Set(load.clone()); + data.cache_time = Set(now); + let db = WEB_CACHE_DB.get().unwrap().lock().await; + data.update(db.deref()).await?; + drop(db); + } + None => { + let data = web_cache::ActiveModel { + cache_key: Set(key), + cache_content: Set(load.clone()), + cache_time: Set(now), + ..Default::default() + }; + let db = WEB_CACHE_DB.get().unwrap().lock().await; + web_cache::Entity::insert(data).exec(db.deref()).await?; + drop(db); + } + } + drop(lock); + Ok(load) +} + +pub(crate) async fn clean_web_cache_by_patten(patten: String) -> Result { + let db = WEB_CACHE_DB.get().unwrap().lock().await; + web_cache::Entity::delete_many() + .filter(web_cache::Column::CacheKey.like(patten.as_str())) + .exec(db.deref()) + .await?; // 不管有几条被作用 + drop(db); + Ok("".to_owned()) +} + +pub(crate) async fn clean_all_web_cache() -> Result { + web_cache::Entity::delete_many() + .exec(WEB_CACHE_DB.get().unwrap().lock().await.deref()) + .await?; + Ok(String::default()) +} + +pub(crate) async fn clean_web_cache_by_time(time: i64) -> Result { + web_cache::Entity::delete_many() + .filter(web_cache::Column::CacheTime.lt(time)) + .exec(WEB_CACHE_DB.get().unwrap().lock().await.deref()) + .await?; + Ok(String::default()) +} diff --git a/native/jmbackend/src/database/web_cache_db/web_cache.rs b/native/jmbackend/src/database/web_cache_db/web_cache.rs new file mode 100644 index 0000000..33c7844 --- /dev/null +++ b/native/jmbackend/src/database/web_cache_db/web_cache.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; +use sea_orm::EntityTrait; + +use crate::database::utils::{create_index, create_table_if_not_exists, index_exists}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "web_cache")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub cache_key: String, + pub cache_content: String, + pub cache_time: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +pub(crate) async fn init(db: &DatabaseConnection) { + create_table_if_not_exists(db, Entity).await; + if !index_exists(db, "web_cache", "idx_cache_time").await { + create_index(db, "web_cache", vec!["cache_time"], "idx_cache_time").await; + } +} diff --git a/native/jmbackend/src/define.rs b/native/jmbackend/src/define.rs new file mode 100644 index 0000000..2c1f9ba --- /dev/null +++ b/native/jmbackend/src/define.rs @@ -0,0 +1,59 @@ +use jmcomic::Client; +use std::collections::hash_map::DefaultHasher; +use std::hash::Hasher; +use std::time::Duration; + +use lazy_static::lazy_static; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; +use tokio::sync::MutexGuard; + +use crate::types::*; + +pub const HASH_LOCK_COUNT: u64 = 64; + +lazy_static! { + + pub static ref UA:&'static str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"; + + pub static ref RUNTIME: Runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_keep_alive(Duration::new(60, 0)) + .worker_threads(30) + .max_blocking_threads(30) + .build() + .unwrap(); + + pub(crate) static ref CONTEXT: Mutex = + Mutex::::new(BackendContext { + login: false, + last_login: 0, + }); + + pub(crate) static ref FIRST_LOGIN: Mutex = Mutex::new(false); + + pub(crate) static ref CLIENT :Client = Client::new(); +} + +lazy_static! { + pub(crate) static ref INITED: Mutex = Mutex::::new(false); + pub(crate) static ref FOLDER: Mutex = Mutex::::new(String::new()); +} + +lazy_static::lazy_static! { + static ref HASH_LOCK: Vec> = { + let mut mutex_vec = vec![]; + for _ in 0..HASH_LOCK_COUNT { + mutex_vec.push(Mutex::<()>::new(())); + } + mutex_vec + }; +} + +pub(crate) async fn take_hash_lock(url: String) -> MutexGuard<'static, ()> { + let mut s = DefaultHasher::new(); + s.write(url.as_bytes()); + HASH_LOCK[(s.finish() % HASH_LOCK_COUNT) as usize] + .lock() + .await +} diff --git a/native/jmbackend/src/download.rs b/native/jmbackend/src/download.rs new file mode 100644 index 0000000..c730dc8 --- /dev/null +++ b/native/jmbackend/src/download.rs @@ -0,0 +1,570 @@ +use std::collections::VecDeque; +use std::fs::create_dir_all; +use std::ops::Deref; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use itertools::Itertools; +use lazy_static::lazy_static; +use once_cell::sync::OnceCell; +use sea_orm::ActiveValue::Set; +use sea_orm::DbErr; +use sea_orm::TransactionTrait; +use sea_orm::{ActiveModelTrait, ConnectionTrait}; +use serde_json::{from_str, to_string}; +use tokio::sync::Mutex; +use tokio::time::sleep; + +use crate::database::active_db::{dl_album, dl_chapter, dl_image, ACTIVE_DB}; +use crate::tools::join_paths; +use crate::{download_image_from_url, load_download_thread, page_image_key, CLIENT}; +use crate::{DownloadCreate, DownloadCreateAlbum}; +use crate::{DownloadCreateChapter, Result}; + +pub(crate) static DOWNLOAD_FOLDER: OnceCell = OnceCell::new(); + +pub(crate) async fn init_dir() { + let dir = join_paths(vec![crate::FOLDER.lock().await.deref(), "download"]); + tokio::fs::create_dir_all(dir.clone()).await.unwrap(); + DOWNLOAD_FOLDER.set(dir).expect("INIT ACTIVE DB DUP"); +} + +lazy_static! { + pub(crate) static ref RESTART_FLAG: Mutex = Mutex::new(false); + pub(crate) static ref DOWNLOAD_AND_EXPORT_TO: Mutex = Mutex::new("".to_owned()); +} + +async fn need_restart() -> bool { + *RESTART_FLAG.lock().await.deref() +} + +// +pub(crate) async fn start_download() { + loop { + // 检测重启flag + let mut restart_flag = RESTART_FLAG.lock().await; + if *restart_flag.deref() { + *restart_flag = false; + } + drop(restart_flag); + // 删除 + let mut need_delete = load_first_need_delete_album().await; + while need_delete.is_some() { + delete_file_and_database(need_delete.unwrap()).await; + need_delete = load_first_need_delete_album().await; + } + // 下载 + match load_first_need_download_album().await { + None => sleep(Duration::new(3, 0)).await, + Some(album) => { + println!("LOAD ALBUM : {}", album.id); + let album_dir = join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &format!("{}", album.id), + ]); + create_dir_if_not_exists(&album_dir); + download_cover(&album_dir, &album).await; + if need_restart().await { + continue; + } + let chapters = load_chapters(&album).await; + for chapter in &chapters { + let chapter_dir = join_paths(vec![&album_dir, &format!("{}", chapter.id)]); + create_dir_if_not_exists(&chapter_dir); + + let images = Arc::new(Mutex::new(VecDeque::from( + load_all_need_download_image(&chapter).await, + ))); + + let _ = futures_util::future::join_all( + num_iter::range(0, load_download_thread().await.unwrap_or(1)) + .map(|_| download_line(&chapter_dir, images.clone())) + .collect_vec(), + ) + .await; + + if need_restart().await { + break; + } + + println!("PRE SUMMARY chapter : {}", chapter.id); + summary_chapter(chapter.id).await; + } + if need_restart().await { + continue; + } + println!("PRE SUMMARY album : {}", album.id); + summary_album(album.id).await; + } + }; + } +} + +pub(crate) async fn delete_file_and_database(album: dl_album::Model) { + println!("DELETE ALBUM : {}", album.id); + let album_dir = join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &format!("{}", album.id), + ]); + if Path::new(&album_dir).exists() { + let _ = tokio::fs::remove_dir_all(&album_dir).await; + } + crate::database::active_db::clear_download_album(album.id).await; +} + +async fn download_cover(album_dir: &str, album: &dl_album::Model) { + if album.dl_3x4_cover_status == 0 { + let url = CLIENT.comic_cover_url_3x4(album.id).await; + let data = download_image_from_url(&url, 0, String::new()).await; + match data { + Err(_) => { + dl_album::set_3x4_cover_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + album.id, + 2, + ) + .await + } + Ok((data, _, _)) => { + tokio::fs::write(&join_paths(vec![album_dir, "cover_3x4"]), data) + .await + .unwrap(); + dl_album::set_3x4_cover_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + album.id, + 1, + ) + .await; + } + } + } + if album.dl_square_cover_status == 0 { + let url = CLIENT.comic_cover_url_square(album.id).await; + let data = download_image_from_url(&url, 0, String::new()).await; + match data { + Err(_) => { + dl_album::set_square_cover_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + album.id, + 2, + ) + .await + } + Ok((data, _, _)) => { + tokio::fs::write(&join_paths(vec![album_dir, "cover_square"]), data) + .await + .unwrap(); + dl_album::set_square_cover_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + album.id, + 1, + ) + .await; + } + } + } +} + +async fn summary_chapter(chapter_id: i64) { + let lock = ACTIVE_DB.get().unwrap().lock().await; + let chapter = dl_chapter::find_by_id(lock.deref(), chapter_id) + .await + .unwrap(); + match chapter.load_images == 1 + && dl_image::has_not_success_images(lock.deref(), chapter_id).await + { + true => { + println!("SUMMARY CHAPTER : {} : FAIL", chapter_id); + dl_chapter::set_dl_status(lock.deref(), chapter_id, 2).await + } + false => { + println!("SUMMARY CHAPTER : {} : SUCCESS", chapter_id); + dl_chapter::set_dl_status(lock.deref(), chapter_id, 1).await + } + }; +} + +async fn summary_album(album_id: i64) { + // todo check album cover + let lock = ACTIVE_DB.get().unwrap().lock().await; + match dl_chapter::has_not_success_chapter(lock.deref(), album_id).await { + true => { + println!("SUMMARY ALBUM : {} : FAIL", album_id); + dl_album::set_dl_status(lock.deref(), album_id, 2).await + } + false => { + println!("SUMMARY ALBUM : {} : SUCCESS", album_id); + dl_album::set_dl_status(lock.deref(), album_id, 1).await + } + }; +} + +async fn download_line( + chapter_dir: &str, + deque: Arc>>, +) -> Result<()> { + loop { + if need_restart().await { + break; + } + let mut model_stream = deque.lock().await; + let model = model_stream.pop_back(); + drop(model_stream); + if let Some(image) = model { + let _ = download_image(&chapter_dir, &image).await; + } else { + break; + } + } + Ok(()) +} + +async fn download_image(chapter_dir: &str, image: &dl_image::Model) { + let image = image.clone(); + let url = CLIENT + .comic_page_url(image.chapter_id, image.name.clone()) + .await; + let result = download_image_from_url(&url, image.chapter_id, image.name.clone()).await; + match result { + Err(err) => { + println!("ERR : {}", err.to_string()); + dl_image::set_dl_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + image.chapter_id, + image.image_index, + 0, + 0, + 0, + ) + .await + } + Ok((buff, width, height)) => { + { + let exp = DOWNLOAD_AND_EXPORT_TO.lock().await; + if !exp.is_empty() { + let dir = join_paths(vec![ + exp.as_str(), + image.album_id.to_string().as_str(), + image.chapter_id.to_string().as_str(), + ]); + if !Path::new(&dir).exists() { + let _ = tokio::fs::create_dir_all(&dir).await; + } + drop(exp); + let path = join_paths(vec![&dir, &image.name]); + let _ = tokio::fs::write(path, buff.clone()).await; + } + } + std::fs::write( + join_paths(vec![chapter_dir, format!("{}", image.image_index).as_str()]), + buff.clone(), + ) + .unwrap(); + ACTIVE_DB + .get() + .unwrap() + .lock() + .await + .transaction::<_, (), DbErr>(|db| { + Box::pin(async move { + dl_image::set_dl_status( + db, + image.chapter_id, + image.image_index, + 1, + width.try_into().unwrap(), + height.try_into().unwrap(), + ) + .await; + dl_chapter::download_one_image(db, image.chapter_id).await; + dl_album::download_one_image(db, image.album_id).await; + Ok(()) + }) + }) + .await + .unwrap(); + } + } +} + +fn create_dir_if_not_exists>(path: P) { + if !path.as_ref().exists() { + create_dir_all(path).unwrap(); + } +} + +async fn load_chapters(album: &dl_album::Model) -> Vec { + let album = album.clone(); + let chapters = load_all_need_download_chapter(&album).await; + for chapter in &chapters { + if chapter.load_images == 0 { + let chapter = chapter.clone(); + match CLIENT.chapter(chapter.id).await { + Err(_) => { + dl_chapter::set_dl_status( + ACTIVE_DB.get().unwrap().lock().await.deref(), + chapter.id, + 1, + ) + .await + } + Ok(load) => { + // 设置已经下载图片和图片个数 + ACTIVE_DB + .get() + .unwrap() + .lock() + .await + .transaction::<_, (), DbErr>(|db| { + Box::pin(async move { + let images = &load.images; + for idx in 0..images.len() { + let image = &images[idx]; + dl_image::ActiveModel { + album_id: Set(chapter.album_id), + chapter_id: Set(chapter.id), + image_index: Set(idx.try_into().unwrap()), + name: Set(image.to_string()), + key: Set(page_image_key(chapter.id, image)), + dl_status: Set(0), + width: Set(0), + height: Set(0), + ..Default::default() + } + .insert(db) + .await + .unwrap(); + } + dl_chapter::save_image_count( + db, + chapter.id, + images.len().try_into().unwrap(), + ) + .await; + dl_album::inc_image_count( + db, + album.id, + images.len().try_into().unwrap(), + ) + .await; + Ok(()) + }) + }) + .await + .unwrap(); + } + }; + } + } + load_all_need_download_chapter(&album).await +} + +async fn load_first_need_download_album() -> Option { + return dl_album::load_first_need_download_album(ACTIVE_DB.get().unwrap().lock().await.deref()) + .await; +} + +async fn load_first_need_delete_album() -> Option { + return dl_album::load_first_need_delete_album(ACTIVE_DB.get().unwrap().lock().await.deref()) + .await; +} + +async fn load_all_need_download_chapter(album: &dl_album::Model) -> Vec { + dl_chapter::load_all_need_download_chapter( + ACTIVE_DB.get().unwrap().lock().await.deref(), + &album, + ) + .await +} + +async fn load_all_need_download_image(chapter: &dl_chapter::Model) -> Vec { + dl_image::load_all_need_download_image(ACTIVE_DB.get().unwrap().lock().await.deref(), &chapter) + .await +} + +pub(crate) async fn create_download(params: &str) -> Result { + let create: DownloadCreate = from_str(params)?; + // todo upddate + ACTIVE_DB + .get() + .unwrap() + .lock() + .await + .transaction::<_, (), DbErr>(|db| { + Box::pin(async move { + let album = match dl_album::find_by_id(db, create.album.id).await { + None => { + dl_album::ActiveModel { + id: Set(create.album.id), + name: Set(create.album.name), + author: Set(to_string(&create.album.author).unwrap()), + tags: Set(to_string(&create.album.tags).unwrap()), + works: Set(to_string(&create.album.tags).unwrap()), + description: Set(create.album.description), + dl_square_cover_status: Set(0), + dl_3x4_cover_status: Set(0), + dl_status: Set(0), + image_count: Set(0), + dled_image_count: Set(0), + ..Default::default() + } + .insert(db) + .await? + } + Some(model) => { + dl_album::set_dl_status(db, create.album.id, 0).await; + model + } + }; + for chapter in &create.chapters { + match dl_chapter::find_by_id(db, chapter.id).await { + None => { + dl_chapter::ActiveModel { + album_id: Set(album.id), + id: Set(chapter.id), + name: Set(chapter.name.to_string()), + sort: Set(chapter.sort.to_string()), + load_images: Set(0), + image_count: Set(0), + dled_image_count: Set(0), + dl_status: Set(0), + ..Default::default() + } + .insert(db) + .await?; + () + } + Some(_) => (), + } + } + Ok(()) + }) + }) + .await + .unwrap(); + Ok(String::new()) +} + +pub(crate) async fn download_by_id(id: i64) -> Result { + let lock = ACTIVE_DB.get().unwrap().lock().await; + match dl_album::find_by_id(lock.deref(), id).await { + None => Ok("null".to_owned()), + Some(album) => { + let chapters = dl_chapter::list_by_album_id(lock.deref(), id).await; + Ok(to_string(&DownloadCreate { + album: DownloadCreateAlbum { + id: album.id, + name: album.name, + author: from_str(&album.author).unwrap(), + tags: from_str(&album.tags).unwrap(), + works: from_str(&album.works).unwrap(), + description: album.description, + }, + chapters: chapters + .iter() + .map(|chapter| DownloadCreateChapter { + id: chapter.id, + name: chapter.name.clone(), + sort: chapter.sort.clone(), + }) + .collect_vec(), + }) + .unwrap()) + } + } +} + +pub(crate) async fn all_downloads() -> Result { + Ok(to_string( + &dl_album::all(ACTIVE_DB.get().unwrap().lock().await.deref()).await, + )?) +} + +pub(crate) async fn page_image_by_key(key: &str) -> Option { + match dl_image::find_by_key(ACTIVE_DB.get().unwrap().lock().await.deref(), key).await { + None => None, + Some(model) => Some(join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &model.album_id.to_string(), + &model.chapter_id.to_string(), + &model.image_index.to_string(), + ])), + } +} + +pub(crate) async fn jm_3x4_cover_by_id(id: i64) -> Option { + match dl_album::find_by_id(ACTIVE_DB.get().unwrap().lock().await.deref(), id).await { + None => None, + Some(model) => match model.dl_3x4_cover_status { + 1 => Some(join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &model.id.to_string(), + "cover_3x4", + ])), + _ => None, + }, + } +} + +pub(crate) async fn jm_square_cover_by_id(id: i64) -> Option { + match dl_album::find_by_id(ACTIVE_DB.get().unwrap().lock().await.deref(), id).await { + None => None, + Some(model) => match model.dl_square_cover_status { + 1 => Some(join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &model.id.to_string(), + "cover_square", + ])), + _ => None, + }, + } +} + +pub(crate) async fn dl_image_by_chapter_id(chapter_id: i64) -> Result { + Ok(to_string( + &dl_image::find_by_chapter_id(ACTIVE_DB.get().unwrap().lock().await.deref(), chapter_id) + .await, + )?) +} + +pub(crate) async fn delete_download(id: i64) -> Result { + let lock = ACTIVE_DB.get().unwrap().lock().await; + let mut restart_flag = RESTART_FLAG.lock().await; + if *restart_flag.deref() { + *restart_flag = true; + } + // delete_flag + dl_album::set_dl_status(lock.deref(), id, 3).await; + drop(restart_flag); + Ok(String::default()) +} + +pub(crate) async fn delete_download_no_lock(db: &impl ConnectionTrait, id: i64) -> Result { + let mut restart_flag = RESTART_FLAG.lock().await; + if *restart_flag.deref() { + *restart_flag = true; + } + // delete_flag + dl_album::set_dl_status(db, id, 3).await; + drop(restart_flag); + Ok(String::default()) +} + +pub(crate) async fn renew_all_downloads() -> Result { + let lock = ACTIVE_DB.get().unwrap().lock().await; + let mut restart_flag = RESTART_FLAG.lock().await; + if *restart_flag.deref() { + *restart_flag = true; + } + lock.transaction::<_, (), DbErr>(|db| { + Box::pin(async move { + dl_album::renew_failed(db).await; + dl_chapter::renew_failed(db).await; + dl_image::renew_failed(db).await; + Ok(()) + }) + }) + .await?; + Ok(String::default()) +} diff --git a/native/jmbackend/src/export.rs b/native/jmbackend/src/export.rs new file mode 100644 index 0000000..541b3ed --- /dev/null +++ b/native/jmbackend/src/export.rs @@ -0,0 +1,1047 @@ +use std::ops::Deref; +use std::path::Path; + +use anyhow::{anyhow, Context}; +use async_zip::{Compression, ZipEntryBuilder, ZipEntryBuilderExt}; +use image::EncodableLayout; +use itertools::Itertools; +use sea_orm::{ConnectionTrait, DbErr, IntoActiveModel}; +use sea_orm::{EntityTrait, TransactionTrait}; +use serde_json::{from_str, to_string}; +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufWriter}; + +use crate::database::active_db::{dl_album, dl_chapter, dl_image, ACTIVE_DB}; +use crate::download::{delete_download_no_lock, DOWNLOAD_FOLDER}; +use crate::tools::join_paths; +use crate::{is_pro, ExportQuery, ExportSingleQuery, Result}; + +use serde_derive::Deserialize; +use serde_derive::Serialize; + +#[async_trait::async_trait] +trait ArchiveWriter { + async fn write_to(&mut self, path: String, data: &mut [u8]) -> Result<()>; + async fn finish(mut self) -> Result<()>; +} + +struct JmiWriter<'a>(async_zip::write::ZipFileWriter<&'a mut BufWriter>); +struct ZipWriter<'a>(async_zip::write::ZipFileWriter<&'a mut BufWriter>); +struct JpegsWriter<'a>(&'a String); + +const K: u8 = 170; + +#[async_trait::async_trait] +impl ArchiveWriter for JmiWriter<'_> { + async fn write_to(&mut self, path: String, data: &'_ mut [u8]) -> Result<()> { + for i in 0..data.len() { + data[i] ^= K; + } + let builder = ZipEntryBuilder::new(path, Compression::Deflate); + let builder = builder.unix_permissions(644); + self.0.write_entry_whole(builder, data).await?; + Ok(()) + } + async fn finish(self) -> Result<()> { + self.0.close().await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl ArchiveWriter for ZipWriter<'_> { + async fn write_to(&mut self, path: String, data: &mut [u8]) -> Result<()> { + let builder = ZipEntryBuilder::new(path, Compression::Deflate); + let builder = builder.unix_permissions(644); + self.0.write_entry_whole(builder, data).await?; + Ok(()) + } + async fn finish(self) -> Result<()> { + self.0.close().await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl ArchiveWriter for JpegsWriter<'_> { + async fn write_to(&mut self, path: String, data: &mut [u8]) -> Result<()> { + let path = join_paths(vec![self.0.as_str(), path.as_str()]); + tokio::fs::write(path, data).await?; + Ok(()) + } + async fn finish(mut self) -> Result<()> { + Ok(()) + } +} + +#[async_trait::async_trait] +trait ArchiveReader { + async fn read_path( + &self, + reader: &mut async_zip::read::fs::ZipFileReader, + path: &str, + ) -> Result>; +} + +struct JmiReader; +struct ZipReader; + +#[async_trait::async_trait] +impl ArchiveReader for JmiReader { + async fn read_path( + &self, + reader: &mut async_zip::read::fs::ZipFileReader, + path: &str, + ) -> Result> { + let entry = reader + .entry(path) + .with_context(|| format!("not found {}", path))?; + let mut e = reader.entry_reader(entry.0).await?; + let mut data = vec![]; + e.read_to_end(&mut data).await?; + for i in 0..data.len() { + data.as_mut_slice()[i] ^= K; + } + Ok(data) + } +} + +#[async_trait::async_trait] +impl ArchiveReader for ZipReader { + async fn read_path( + &self, + reader: &mut async_zip::read::fs::ZipFileReader, + path: &str, + ) -> Result> { + let entry = reader + .entry(path) + .with_context(|| format!("not found {}", path))?; + let mut e = reader.entry_reader(entry.0).await?; + let mut data = vec![]; + e.read_to_end(&mut data).await?; + Ok(data) + } +} + +fn local_name(name: &str) -> String { + name.replace("\\", "_") + .replace("/", "_") + .replace("*", "_") + .replace("%", "_") + .replace("&", "_") + .replace("$", "_") + .replace(" ", "_") + .replace("(", "_") + .replace(")", "_") + .replace("[", "_") + .replace("]", "_") + .replace("?", "_") + .replace("<", "_") + .replace(">", "_") + .replace("|", "_") + .replace("\"", "_") + .replace("'", "_") +} + +pub(crate) async fn export_jm_jpegs(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + let mut paths: Vec = vec![]; + let query: ExportQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + for comic_id in query.comic_id { + let ab = dl_album::find_by_id(db.deref(), comic_id) + .await + .with_context(|| "not found")?; + let archive_path = join_paths(vec![ + query.dir.as_str(), + format!( + "{}-{}", + local_name(ab.name.as_str()), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + tokio::fs::create_dir_all(archive_path.as_str()).await?; + put_comic_to_zip(Box::new(JpegsWriter(&archive_path)), db.deref(), ab, true).await?; + paths.push(archive_path); + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), comic_id).await; + } + } + Ok(to_string(&paths)?) +} + +pub(crate) async fn export_jm_zip(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + let mut paths: Vec = vec![]; + let query: ExportQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + for comic_id in query.comic_id { + let ab = dl_album::find_by_id(db.deref(), comic_id) + .await + .with_context(|| "not found")?; + let archive_path = join_paths(vec![ + query.dir.as_str(), + format!( + "{}-{}.jm.zip", + local_name(ab.name.as_str()), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let writer_file = writer_file.unwrap(); + let mut buff_writer = tokio::io::BufWriter::new(writer_file); + let writer = async_zip::write::ZipFileWriter::new(&mut buff_writer); + let write_result1 = + put_comic_to_zip(Box::new(ZipWriter(writer)), db.deref(), ab, false).await; + let write_result3 = buff_writer.flush().await; + if write_result1.is_err() { + // todo delete file + return Err(anyhow!("{}", write_result1.err().unwrap().to_string())); + } + if write_result3.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result3.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!("{}", writer_file.err().unwrap().to_string())); + } + paths.push(archive_path); + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), comic_id).await; + } + } + Ok(to_string(&paths)?) +} + +pub(crate) async fn export_jm_zip_single(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + + let query: ExportSingleQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + let ab = dl_album::find_by_id(db.deref(), query.id) + .await + .with_context(|| "not found")?; + + let archive_path = join_paths(vec![ + query.folder.as_str(), + format!( + "{}-{}.jm.zip", + local_name( + if let Some(rename) = query.rename { + rename + } else { + ab.name.clone() + } + .as_str() + ), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let writer_file = writer_file.unwrap(); + let mut buff_writer = tokio::io::BufWriter::new(writer_file); + let writer = async_zip::write::ZipFileWriter::new(&mut buff_writer); + let write_result1 = + put_comic_to_zip(Box::new(ZipWriter(writer)), db.deref(), ab, false).await; + let write_result3 = buff_writer.flush().await; + if write_result1.is_err() { + // todo delete file + return Err(anyhow!("{}", write_result1.err().unwrap().to_string())); + } + if write_result3.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result3.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!("{}", writer_file.err().unwrap().to_string())); + } + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), query.id).await; + } + Ok(archive_path) +} + +pub(crate) async fn export_jm_jpegs_zip_single(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + + let query: ExportSingleQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + let ab = dl_album::find_by_id(db.deref(), query.id) + .await + .with_context(|| "not found")?; + + let archive_path = join_paths(vec![ + query.folder.as_str(), + format!( + "{}-{}.jm.jpegs.zip", + local_name( + if let Some(rename) = query.rename { + rename + } else { + ab.name.clone() + } + .as_str() + ), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let writer_file = writer_file.unwrap(); + let mut buff_writer = tokio::io::BufWriter::new(writer_file); + let writer = async_zip::write::ZipFileWriter::new(&mut buff_writer); + let write_result1 = + put_comic_to_zip(Box::new(ZipWriter(writer)), db.deref(), ab, true).await; + let write_result3 = buff_writer.flush().await; + if write_result1.is_err() { + // todo delete file + return Err(anyhow!("{}", write_result1.err().unwrap().to_string())); + } + if write_result3.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result3.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!("{}", writer_file.err().unwrap().to_string())); + } + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), query.id).await; + } + Ok(archive_path) +} + +pub(crate) async fn export_jm_jmi(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + let mut paths: Vec = vec![]; + let query: ExportQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + for comic_id in query.comic_id { + let ab = dl_album::find_by_id(db.deref(), comic_id) + .await + .with_context(|| "not found")?; + let archive_path = join_paths(vec![ + query.dir.as_str(), + format!( + "{}-{}.jmi", + local_name(ab.name.as_str()), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let writer_file = writer_file.unwrap(); + let mut buff_writer = tokio::io::BufWriter::new(writer_file); + let writer = async_zip::write::ZipFileWriter::new(&mut buff_writer); + let write_result1 = + put_comic_to_zip(Box::new(JmiWriter(writer)), db.deref(), ab, false).await; + let write_result3 = buff_writer.flush().await; + if write_result1.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result1.err().unwrap().to_string(), + archive_path + )); + } + if write_result3.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result3.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!( + "{} : {}", + writer_file.err().unwrap().to_string(), + archive_path + )); + } + paths.push(archive_path); + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), comic_id).await; + } + } + Ok(to_string(&paths)?) +} + +pub(crate) async fn export_jm_jmi_single(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + + let query: ExportSingleQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + let ab = dl_album::find_by_id(db.deref(), query.id) + .await + .with_context(|| "not found")?; + + let archive_path = join_paths(vec![ + query.folder.as_str(), + format!( + "{}-{}.jmi", + local_name( + if let Some(rename) = query.rename { + rename + } else { + ab.name.clone() + } + .as_str() + ), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let writer_file = writer_file.unwrap(); + let mut buff_writer = tokio::io::BufWriter::new(writer_file); + let writer = async_zip::write::ZipFileWriter::new(&mut buff_writer); + let write_result1 = + put_comic_to_zip(Box::new(JmiWriter(writer)), db.deref(), ab, false).await; + let write_result3 = buff_writer.flush().await; + if write_result1.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result1.err().unwrap().to_string(), + archive_path + )); + } + if write_result3.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result3.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!("{}", writer_file.err().unwrap().to_string())); + } + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), query.id).await; + } + Ok(archive_path) +} + +pub(crate) async fn import_jm_zip(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + import_archive(params, ZipReader).await +} + +pub(crate) async fn import_jm_jmi(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + import_archive(params, JmiReader).await +} + +pub(crate) async fn import_jm_dir(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + + let paths = std::fs::read_dir(params).unwrap(); + for path in paths { + let entry = path?; + let path = entry + .path() + .to_str() + .with_context(|| "What's up")? + .to_owned(); + if path.ends_with(".jm.zip") { + let _ = import_jm_zip(entry.path().to_str().with_context(|| "What's up")?).await; + } else if path.ends_with(".jmi") { + let _ = import_jm_jmi(entry.path().to_str().with_context(|| "What's up")?).await; + } + } + Ok("".to_string()) +} + +async fn import_archive(params: &str, x: impl ArchiveReader) -> Result { + let mut zip = async_zip::read::fs::ZipFileReader::new(params).await?; + // 读取基本信息 + let comic: dl_album::Model = + from_str(String::from_utf8(x.read_path(&mut zip, "comic.json").await?)?.as_str())?; + let chapters: Vec = + from_str(String::from_utf8(x.read_path(&mut zip, "chapters.json").await?)?.as_str())?; + let images: Vec = + from_str(String::from_utf8(x.read_path(&mut zip, "images.json").await?)?.as_str())?; + + // 删除数据库 + let db = ACTIVE_DB.get().unwrap().lock().await; + db.transaction::<_, (), DbErr>(|txn| { + Box::pin(async move { + dl_album::delete_by_album_id(txn, comic.id).await?; + dl_chapter::delete_by_album_id(txn, comic.id).await?; + dl_image::delete_by_album_id(txn, comic.id).await?; + Ok(()) + }) + }) + .await?; + // 删除文件夹 + let album_dir = join_paths(vec![ + &DOWNLOAD_FOLDER.get().unwrap(), + &format!("{}", comic.id), + ]); + if Path::new(&album_dir).exists() { + let _ = tokio::fs::remove_dir_all(&album_dir).await; + } + // 导入数据库 + let images1 = images.clone(); + db.transaction::<_, (), DbErr>(|txn| { + Box::pin(async move { + dl_album::Entity::insert(comic.clone().into_active_model()) + .exec(txn) + .await?; + dl_chapter::Entity::insert_many( + chapters + .iter() + .map(|x| x.clone().into_active_model()) + .collect_vec(), + ) + .exec(txn) + .await?; + dl_image::Entity::insert_many( + images1 + .iter() + .map(|x| x.clone().into_active_model()) + .collect_vec(), + ) + .exec(txn) + .await?; + Ok(()) + }) + }) + .await?; + + // 导入logo + if !Path::new(&album_dir).exists() { + tokio::fs::create_dir_all(&album_dir).await?; + } + let path_3x4_cover = join_paths(vec![album_dir.as_str(), "cover_3x4"]); + let path_cover_square = join_paths(vec![album_dir.as_str(), "cover_square"]); + { + let image_data = x.read_path(&mut zip, "cover_3x4").await?; + tokio::fs::write(&path_3x4_cover, image_data).await?; + } + { + let image_data = x.read_path(&mut zip, "cover_square").await?; + tokio::fs::write(&path_cover_square, image_data).await?; + } + + // 导入图片 + for image in images { + let chapter_dir = join_paths(vec![album_dir.as_str(), &format!("{}", image.chapter_id)]); + if !Path::new(&chapter_dir).exists() { + tokio::fs::create_dir_all(&chapter_dir).await?; + } + let image_path = join_paths(vec![ + chapter_dir.as_str(), + format!("{}", image.image_index).as_str(), + ]); + let in_zip = format!( + "{}_{}_{}", + image.album_id, image.chapter_id, image.image_index + ); + let buff = x.read_path(&mut zip, in_zip.as_str()).await?; + tokio::fs::write(image_path.as_str(), buff).await?; + } + Ok("".to_owned()) +} + +async fn put_comic_to_zip( + mut x: Box, + db: &impl ConnectionTrait, + ab: dl_album::Model, + ext: bool, +) -> Result<()> { + let chapters = dl_chapter::list_by_album_id(db, ab.id.clone()).await; + let images = dl_image::lisst_by_album_id(db, ab.id.clone()).await?; + + let comic_json_str = to_string(&ab)?; + let chapters_json_str = to_string(&chapters)?; + let images_json_str = to_string(&images)?; + + x.write_to( + "comic.json".to_string(), + &mut comic_json_str.as_bytes().to_vec(), + ) + .await?; + + x.write_to( + "comic.js".to_string(), + &mut format!("comic = {}", comic_json_str).as_bytes().to_vec(), + ) + .await?; + + x.write_to( + "chapters.json".to_string(), + &mut chapters_json_str.as_bytes().to_vec(), + ) + .await?; + + x.write_to( + "chapters.js".to_string(), + &mut format!("chapters = {}", chapters_json_str) + .as_bytes() + .to_vec(), + ) + .await?; + + x.write_to( + "images.json".to_string(), + &mut images_json_str.as_bytes().to_vec(), + ) + .await?; + + x.write_to( + "images.js".to_string(), + &mut format!("images = {}", images_json_str).as_bytes().to_vec(), + ) + .await?; + + let album_dir = join_paths(vec![&DOWNLOAD_FOLDER.get().unwrap(), &format!("{}", ab.id)]); + + // 写logo + let path_3x4_cover = join_paths(vec![album_dir.as_str(), "cover_3x4"]); + let path_cover_square = join_paths(vec![album_dir.as_str(), "cover_square"]); + + if Path::new(&path_3x4_cover).exists() { + let mut image_data = tokio::fs::read(&path_3x4_cover).await?; + x.write_to("cover_3x4".to_owned(), &mut image_data).await?; + } + if Path::new(&path_cover_square).exists() { + let mut image_data = tokio::fs::read(&path_cover_square).await?; + x.write_to("cover_square".to_owned(), &mut image_data) + .await?; + } + + // 写入图片 + for image in images { + let chapter_dir = join_paths(vec![album_dir.as_str(), &format!("{}", image.chapter_id)]); + let image_path = join_paths(vec![ + chapter_dir.as_str(), + format!("{}", image.image_index).as_str(), + ]); + let mut in_zip = format!( + "{}_{}_{}", + image.album_id, image.chapter_id, image.image_index + ); + if ext { + in_zip = format!("{}.jpg", in_zip) + } + if image.dl_status == 1 { + let mut image_data = tokio::fs::read(&image_path).await?; + x.write_to(in_zip, &mut image_data).await?; + } + } + + //写html + x.write_to("index.html".to_string(), &mut HTML.as_bytes().to_vec()) + .await?; + + x.finish().await?; + + Ok(()) +} + +const HTML: &str = " + + + + + + + + + + + +
+
    +
  • + +
  • + +
+ +
+
+
+ + +"; + +pub(crate) async fn export_cbzs_zip_single(params: &str) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("请先发电鸭")); + } + let query: ExportSingleQuery = from_str(params)?; + let db = ACTIVE_DB.get().unwrap().lock().await; + // 找到漫画 + let ab = dl_album::find_by_id(db.deref(), query.id) + .await + .with_context(|| "not found")?; + // 创建文件并创建zip输出流 + let archive_path = join_paths(vec![ + query.folder.as_str(), + format!( + "{}-{}.cbzs.zip", + local_name( + if let Some(rename) = query.rename { + rename + } else { + ab.name.clone() + } + .as_str() + ), + chrono::Local::now().timestamp() + ) + .as_str(), + ]); + let writer_file = tokio::fs::File::create(archive_path.as_str()).await; + if writer_file.is_ok() { + let mut writer_file = writer_file.unwrap(); + let writer = async_zip::write::ZipFileWriter::new(&mut writer_file); + // 导出内容 + let write_result1 = export_cbzs_zip_single_export(writer, db.deref(), ab).await; + // 关闭文件 + drop(writer_file); + if write_result1.is_err() { + // todo delete file + return Err(anyhow!( + "{} : {}", + write_result1.err().unwrap().to_string(), + archive_path + )); + } + } else { + // todo + return Err(anyhow!("{}", writer_file.err().unwrap().to_string())); + } + if query.delete_exported { + let _ = delete_download_no_lock(db.deref(), query.id).await; + } + Ok(archive_path) +} + +pub(crate) async fn export_cbzs_zip_single_export( + mut x: async_zip::write::ZipFileWriter<&mut File>, + db: &impl ConnectionTrait, + ab: dl_album::Model, +) -> Result<()> { + // chpaters + let chapters = dl_chapter::list_by_album_id(db, ab.id.clone()).await; + let mut chapters = chapters + .into_iter() + .map(|c| CbzChapter { + album_id: c.album_id, + id: c.id, + name: c.name, + sort: c.sort, + // 0:未加载图片 1:已加载图片 + load_images: c.load_images, + // 图片总数 + image_count: c.image_count, + // 下载了的图片总数 + dled_image_count: c.dled_image_count, + // image(图片的下载状态) + // 0:未下载, 1:全部下载成功 2:任何一个下载失败 + // "JM_PAGE_IMAGE:{}:{}" + dl_status: c.dl_status, + images: vec![], + cbz_name: String::default(), + }) + .collect::>(); + chapters.sort_by_key(|c| c.sort.clone()); + let mut map = std::collections::HashMap::::new(); + for chapter in &mut chapters { + map.insert(chapter.id.clone(), chapter); + } + // images push to cbzChpater + let images = dl_image::lisst_by_album_id(db, ab.id.clone()).await?; + for image in images { + if let Some(c) = map.get_mut(&image.chapter_id) { + (*c).images.push(CbzImage { + album_id: image.album_id, + chapter_id: image.chapter_id, + image_index: image.image_index, + name: image.name, + key: image.key, + dl_status: image.dl_status, + width: image.width, + height: image.height, + file_name: "".to_string(), + }); + } + } + drop(map); + let mut i = 1; + for chapter in &mut chapters { + chapter.cbz_name = format!("{:04}.cbz", { + let tmp = i; + i += 1; + tmp + }); + for image in &mut chapter.images { + image.file_name = format!("{:04}.jpg", image.image_index); + } + } + // album + let ab = CbzAlbum { + id: ab.id, + name: ab.name, + author: ab.author, + tags: ab.tags, + works: ab.works, + description: ab.description, + dl_square_cover_status: ab.dl_square_cover_status, + dl_3x4_cover_status: ab.dl_3x4_cover_status, + dl_status: ab.dl_status, + image_count: ab.image_count, + dled_image_count: ab.dled_image_count, + chapters, + }; + // export + let album_dir = join_paths(vec![&DOWNLOAD_FOLDER.get().unwrap(), &format!("{}", ab.id)]); + for chapter in &ab.chapters { + let chapter_dir = join_paths(vec![album_dir.as_str(), &format!("{}", chapter.id)]); + let builder = ZipEntryBuilder::new(chapter.cbz_name.clone(), Compression::Deflate); + let builder = builder.unix_permissions(644); + let mut cbz_entry = x.write_entry_stream(builder).await?; + let mut cbz_writer = async_zip::write::ZipFileWriter::new(&mut cbz_entry); + for image in &chapter.images { + let image_path = join_paths(vec![ + chapter_dir.as_str(), + format!("{}", image.image_index).as_str(), + ]); + let image_data = tokio::fs::read(&image_path).await?; + let builder = ZipEntryBuilder::new(image.file_name.clone(), Compression::Deflate); + let builder = builder.unix_permissions(644); + cbz_writer + .write_entry_whole(builder, image_data.as_bytes()) + .await?; + } + cbz_writer.close().await?; + cbz_entry.close().await?; + } + // 写logo + let path_3x4_cover = join_paths(vec![album_dir.as_str(), "cover_3x4"]); + let path_cover_square = join_paths(vec![album_dir.as_str(), "cover_square"]); + if Path::new(&path_3x4_cover).exists() { + let image_data = tokio::fs::read(&path_3x4_cover).await?; + let builder = ZipEntryBuilder::new("cover_3x4.jpg".to_owned(), Compression::Deflate); + let builder = builder.unix_permissions(644); + x.write_entry_whole(builder, image_data.as_bytes()).await?; + } + if Path::new(&path_cover_square).exists() { + let image_data = tokio::fs::read(&path_cover_square).await?; + let builder = ZipEntryBuilder::new("cover_square.jpg".to_owned(), Compression::Deflate); + let builder = builder.unix_permissions(644); + x.write_entry_whole(builder, image_data.as_bytes()).await?; + } + // 写数据 + let json = to_string(&ab)?; + let builder = ZipEntryBuilder::new("z-jm-cbzs-info.json".to_owned(), Compression::Deflate); + let builder = builder.unix_permissions(644); + x.write_entry_whole(builder, json.as_bytes()).await?; + // + x.close().await?; + Ok(()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)] +pub struct CbzAlbum { + pub id: i64, + pub name: String, + pub author: String, + pub tags: String, + pub works: String, + pub description: String, + pub dl_square_cover_status: i32, + pub dl_3x4_cover_status: i32, + pub dl_status: i32, + pub image_count: i32, + pub dled_image_count: i32, + pub chapters: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)] +pub struct CbzChapter { + pub album_id: i64, + pub id: i64, + pub name: String, + pub sort: String, + pub load_images: i32, + pub image_count: i32, + pub dled_image_count: i32, + pub dl_status: i32, + pub images: Vec, + pub cbz_name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)] +pub struct CbzImage { + pub album_id: i64, + pub chapter_id: i64, + pub image_index: i64, + pub name: String, + pub key: String, + pub dl_status: i32, + pub width: u32, + pub height: u32, + pub file_name: String, +} diff --git a/native/jmbackend/src/lib.rs b/native/jmbackend/src/lib.rs new file mode 100644 index 0000000..ff10c90 --- /dev/null +++ b/native/jmbackend/src/lib.rs @@ -0,0 +1,1040 @@ +use std::ffi::{CStr, CString}; +use std::ops::Deref; +use std::path::Path; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use image::codecs::png::PngEncoder; +use image::EncodableLayout; +use image::ImageEncoder; +use image::{ColorType, GenericImageView}; +use jmcomic::*; +use libc::c_char; +use rand::random; +use serde_derive::{Deserialize, Serialize}; +use serde_json::{from_str, to_string}; +use tokio::spawn; + +use database::image_cache_db::{clean_all_image_cache, use_image_cache}; +pub use define::RUNTIME; +use define::*; +use types::*; + +use crate::active_db::{db_clear_a_search_log, db_clear_all_search_log}; +use crate::database::active_db::{ + clear_view_log, find_view_log, last_view_album, load_last_search_histories, page_view_log, + save_search_history, update_view_log, +}; +use crate::database::property_db::{load_property, save_property}; +use crate::database::web_cache_db::{ + clean_all_web_cache, clean_web_cache_by_patten, use_web_cache, +}; +use crate::database::{active_db, image_cache_db, property_db, web_cache_db}; +use crate::download::{ + all_downloads, create_download, delete_download, dl_image_by_chapter_id, download_by_id, + jm_3x4_cover_by_id, jm_square_cover_by_id, page_image_by_key, renew_all_downloads, + DOWNLOAD_AND_EXPORT_TO, RESTART_FLAG, +}; +use crate::export::{ + export_cbzs_zip_single, export_jm_jmi, export_jm_jmi_single, export_jm_jpegs, + export_jm_jpegs_zip_single, export_jm_zip, export_jm_zip_single, import_jm_dir, import_jm_jmi, + import_jm_zip, +}; +use crate::sync::sync_webdav; + +mod database; +mod define; +mod download; +mod export; +mod sync; +mod tools; +mod types; + +#[no_mangle] +pub unsafe extern "C" fn init_ffi(c: *const c_char) { + init_sync(CStr::from_ptr(c).to_str().unwrap()); +} + +#[no_mangle] +pub unsafe extern "C" fn migration_ffi(from: *const c_char, to: *const c_char) { + let from_string = CStr::from_ptr(from).to_str().unwrap(); + let to_string = CStr::from_ptr(to).to_str().unwrap(); + RUNTIME.block_on(migration(from_string, to_string)); +} + +async fn migration(from: &str, _: &str) { + let source = Path::new(from); + if source.exists() { + let mut rd = tokio::fs::read_dir(source).await.unwrap(); + while let Some(item) = rd.next_entry().await.unwrap() { + if item + .file_name() + .to_str() + .unwrap() + .starts_with("property.db") + { + tokio::fs::remove_file(item.path()).await.unwrap(); + } + } + } else { + tokio::fs::create_dir_all(source).await.unwrap(); + } +} + +pub fn init_sync(params: &str) { + RUNTIME.block_on(init(params)); +} + +async fn init(params: &str) { + let mut init_lock = INITED.lock().await; + if *init_lock { + drop(init_lock); + return; + } + *init_lock = true; + drop(init_lock); + + tokio::fs::create_dir_all(params.to_string()).await.unwrap(); + let mut lock = FOLDER.lock().await; + *lock = params.to_string(); + + println!("RUST INIT : {}", lock.clone()); + + drop(lock); + + image_cache_db::init_dir().await; + download::init_dir().await; + + // init_database + active_db::init_db().await; + image_cache_db::init_db().await; + property_db::init_db().await; + web_cache_db::init_db().await; + + // + + // auto clean + let mut auto_clean_time_str = load_property("auto_clean".to_string()).await.unwrap(); + if auto_clean_time_str == "" { + auto_clean_time_str = format!("{}", 3600 * 24 * 30); + save_property("auto_clean".to_string(), auto_clean_time_str.clone()) + .await + .unwrap(); + } + let timestamp: i64 = auto_clean_time_str.parse::().unwrap(); + let timestamp = chrono::Local::now().timestamp() - timestamp; + image_cache_db::clean_image_cache_by_time(timestamp) + .await + .unwrap(); + web_cache_db::clean_web_cache_by_time(timestamp) + .await + .unwrap(); + + let proxy_url = load_property("proxy_url".to_owned()).await.unwrap(); + if proxy_url.len() > 0 { + let _ = set_proxy(&proxy_url).await; + } + + // + + let ua = load_property("ua1".to_owned()).await.unwrap(); + if ua.len() > 0 { + CLIENT.set_user_agent(ua).await; + } else { + let ua = Client::rand_user_agent(); + save_property("ua1".to_owned(), ua.clone()).await.unwrap(); + CLIENT.set_user_agent(ua).await; + } + save_property("ua".to_owned(), "".to_owned()).await.unwrap(); + + if let Ok(is_pro) = is_pro().await { + if is_pro.is_pro { + *DOWNLOAD_AND_EXPORT_TO.lock().await = + load_property("download_and_export_to".to_owned()) + .await + .unwrap(); + } + } + + let _ = spawn(download::start_download()); +} + +async fn get_download_and_export_to() -> Result { + Ok((*DOWNLOAD_AND_EXPORT_TO.lock().await).clone()) +} + +async fn set_download_and_export_to(params: &str) -> Result { + save_property("download_and_export_to".to_owned(), params.to_string()).await?; + *DOWNLOAD_AND_EXPORT_TO.lock().await = params.to_string(); + Ok("".to_string()) +} + +#[no_mangle] +pub unsafe extern "C" fn invoke_ffi(params: *const c_char) -> *mut c_char { + let params = CStr::from_ptr(params).to_str().unwrap(); + let response = invoke(params); + CString::new(response).unwrap().into_raw() +} + +#[no_mangle] +pub unsafe extern "C" fn free_str_ffi(c: *mut c_char) { + drop(CString::from_raw(c)); +} + +pub fn invoke(params: &str) -> String { + RUNTIME.block_on(invoke_async(params)) +} + +pub async fn invoke_async(params: &str) -> String { + let query: DartQuery = serde_json::from_str(params).unwrap(); + let result: Result = match_method(query.method.as_str(), query.params.as_str()).await; + let result: ResponseToDart = match result { + Ok(str) => ResponseToDart { + response_data: str, + error_message: "".to_string(), + }, + Err(err) => ResponseToDart { + response_data: "".to_string(), + error_message: err.to_string(), + }, + }; + serde_json::to_string(&result).unwrap() +} + +async fn match_method(method: &str, params: &str) -> Result { + match method { + "test" => Ok("".to_string()), + "save_api_host" => save_api_host(params).await, + "load_api_host" => load_api_host().await, + "save_cdn_host" => save_cdn_host(params).await, + "load_cdn_host" => load_cdn_host().await, + "load_username" => load_username().await, + "loadLastLoginUsername" => load_last_login_username().await, + "load_password" => load_password().await, + "init" => init_dart().await, + "pre_login" => pre_login().await, + "login" => login(params).await, + "logout" => logout(params).await, + "save_property" => { + let sp: SaveProperty = serde_json::from_str(params)?; + save_property(sp.k, sp.v).await + } + "load_property" => load_property(params.to_owned()).await, + "comics" => comics(params).await, + "comic_search" => comic_search(params).await, + "categories" => categories().await, + "album" => album(params).await, + "chapter" => chapter(params).await, + "forum" => forum(params).await, + "comment" => comment(params).await, + "child_comment" => child_comment(params).await, + "set_favorite" => set_favorite(params).await, + "favorites" => favorites(params).await, + "games" => games(params).await, + "jm_3x4_cover" => jm_3x4_cover(params).await, + "jm_square_cover" => jm_square_cover(params).await, + "jm_page_image" => jm_page_image(params).await, + "jm_photo_image" => jm_photo_image(params).await, + "image_size" => image_size(params).await, + "http_get" => http_get(params).await, + "clean_all_cache" => clean_all_cache().await, + "update_view_log" => update_view_log(from_str(params)?).await, + "find_view_log" => Ok(to_string(&find_view_log(params.parse::()?).await?)?), + "page_view_log" => Ok(to_string(&page_view_log(params.parse::()?).await?)?), + "clear_view_log" => clear_view_log().await, + "last_search_histories" => last_search_histories(params).await, + "create_download" => create_download(params).await, + "all_downloads" => all_downloads().await, + "download_by_id" => download_by_id(params.parse::()?).await, + "dl_image_by_chapter_id" => dl_image_by_chapter_id(params.parse::()?).await, + "delete_download" => delete_download(params.parse::()?).await, + "renew_all_downloads" => renew_all_downloads().await, + "export_jm_jpegs" => export_jm_jpegs(params).await, + "export_jm_zip" => export_jm_zip(params).await, + "export_jm_zip_single" => export_jm_zip_single(params).await, + "export_jm_jpegs_zip_single" => export_jm_jpegs_zip_single(params).await, + "export_jm_jmi" => export_jm_jmi(params).await, + "export_jm_jmi_single" => export_jm_jmi_single(params).await, + "export_cbzs_zip_single" => export_cbzs_zip_single(params).await, + "import_jm_zip" => import_jm_zip(params).await, + "import_jm_jmi" => import_jm_jmi(params).await, + "import_jm_dir" => import_jm_dir(params).await, + "reload_pro" => reload_pro().await, + "is_pro" => async { Ok(to_string(&is_pro().await?)?) }.await, + "input_cd_key" => input_cd_key(params).await, + "set_download_thread" => set_download_thread(params.parse::()?).await, + "load_download_thread" => Ok(load_download_thread().await?.to_string()), + "clear_all_search_log" => clear_all_search_log().await, + "clear_a_search_log" => clear_a_search_log(params).await, + "set_proxy" => set_proxy(params).await, + "get_proxy" => get_proxy().await, + "sync_webdav" => sync_webdav(params).await, + "set_download_and_export_to" => set_download_and_export_to(params).await, + "get_download_and_export_to" => get_download_and_export_to().await, + "ping_server" => ping_server(params).await, + "getHomeDir" => get_home_dir(), + "mkdirs" => mkdirs(params), + "copyPictureToFolder" => copy_picture_to_folder(params).await, + "set_pro_server_name" => set_pro_server_name(params).await, + "get_pro_server_name" => get_pro_server_name().await, + name => return Err(anyhow!("NO FLAT : {}", name)), + } +} + +async fn init_dart() -> Result { + let mut api_host_db = load_property("api_host".to_string()).await?; + if api_host_db.is_empty() { + api_host_db = "1".to_owned(); + save_property("api_host".to_string(), api_host_db.clone()).await?; + } + CLIENT + .set_api_host(match api_host_db.as_str() { + "0" => ApiHost::Default, + "1" => ApiHost::Branch1, + "2" => ApiHost::Branch2, + "3" => ApiHost::Branch3, + _ => ApiHost::Default, + }) + .await; + let cdn_host_db = load_property("cdn_host".to_string()).await?; + if cdn_host_db.is_empty() { + save_property("cdn_host".to_string(), "1".to_owned()).await?; + } + CLIENT + .set_cdn_host(match cdn_host_db.as_str() { + "0" => None, + "1" => Some(CdnHost::Proxy1), + "2" => Some(CdnHost::Proxy2), + _ => None, + }) + .await; + Ok("".to_string()) +} + +async fn save_api_host(params: &str) -> Result { + let api_host = match params.parse::()? { + 0 => ApiHost::Default, + 1 => ApiHost::Branch1, + 2 => ApiHost::Branch2, + 3 => ApiHost::Branch3, + _ => return Err(anyhow!("不支持的分流")), + }; + let _ = save_property("api_host".to_string(), params.to_string()).await?; + CLIENT.set_api_host(api_host).await; + CONTEXT.lock().await.last_login = 0; + Ok("".to_owned()) +} + +async fn load_api_host() -> Result { + Ok(match CLIENT.get_api_host().await { + None => "0".to_owned(), + Some(ApiHost::Default) => "0".to_owned(), + Some(ApiHost::Branch1) => "1".to_owned(), + Some(ApiHost::Branch2) => "2".to_owned(), + Some(ApiHost::Branch3) => "3".to_owned(), + }) +} + +async fn save_cdn_host(params: &str) -> Result { + let cdn_host = match params.parse::()? { + 0 => None, + 1 => Some(CdnHost::Proxy1), + 2 => Some(CdnHost::Proxy2), + _ => return Err(anyhow!("不支持的分流")), + }; + let _ = save_property("cdn_host".to_string(), params.to_string()).await?; + CLIENT.set_cdn_host(cdn_host).await; + Ok("".to_owned()) +} + +async fn load_cdn_host() -> Result { + Ok(match CLIENT.get_cdn_host().await { + None => "0".to_owned(), + Some(CdnHost::Proxy1) => "1".to_owned(), + Some(CdnHost::Proxy2) => "2".to_owned(), + }) +} + +async fn load_download_thread() -> Result { + if is_pro().await?.is_pro { + Ok(property_db::load_int_property("download_thread_count".to_owned(), 1).await) + } else { + Ok(1) + } +} + +async fn set_download_thread(count: i64) -> Result { + if !is_pro().await?.is_pro { + return Err(anyhow!("需要发电鸭")); + } + property_db::save_property("download_thread_count".to_owned(), format!("{count}")).await?; + let mut restart_flag = RESTART_FLAG.lock().await; + if *restart_flag.deref() { + *restart_flag = true; + } + Ok("".to_string()) +} + +async fn load_username() -> Result { + load_property("username".to_owned()).await +} + +async fn load_last_login_username() -> Result { + load_property("last_login_username".to_owned()).await +} + +async fn load_password() -> Result { + load_property("password".to_owned()).await +} + +async fn pre_login() -> Result { + let username = load_property("username".to_owned()).await?; + let password = load_property("password".to_owned()).await?; + check_first().await?; + let data = match username != "" && password != "" { + true => match CLIENT.login(username.clone(), password).await { + Ok(mut info) => { + let mut lock = CONTEXT.lock().await; + lock.login = true; + lock.last_login = chrono::Local::now().timestamp(); + save_property("cookie".to_string(), CLIENT.cookie_str().await).await?; + save_property("last_login_username".to_owned(), username.to_string()).await?; + drop(lock); + if let Ok(json) = load_property("fav_list".to_string()).await { + if json.len() > 0 { + if let Ok(ff) = from_str::(&json) { + if username.eq(&ff.username) { + if let Ok(ff) = from_str::>(&ff.ff_json) { + info.favorite_list = ff; + } + } + } + } + } + PreLoginResponse { + pre_set: true, + pre_login: true, + self_info: Some(info), + message: None, + } + } + Err(err) => PreLoginResponse { + pre_set: true, + pre_login: false, + self_info: None, + message: Some(format!("{:?}", err)), + }, + }, + false => PreLoginResponse { + pre_set: false, + pre_login: false, + self_info: None, + message: None, + }, + }; + Ok(to_string(&data)?) +} + +pub(crate) async fn is_pro() -> Result { + Ok(IsPro { + is_pro: true, + expire: chrono::Local::now().timestamp() + 3600 * 24 * 30, + }) +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IsPro { + pub is_pro: bool, + pub expire: i64, +} + +async fn reload_pro() -> Result { + Ok("".to_string()) +} + + +async fn input_cd_key(_params: &str) -> Result { + Ok("".to_string()) +} + +pub(crate) async fn check_first() -> Result<()> { + let mut lock = FIRST_LOGIN.lock().await; + if !lock.deref() { + let cookie = load_property("cookie".to_string()).await?; + if cookie.is_empty() { + save_property("cookie".to_string(), CLIENT.cookie_str().await).await?; + } else { + CLIENT.set_cookie(&cookie).await?; + } + } + *lock = true; + Ok(()) +} + +async fn login(params: &str) -> Result { + check_first().await?; + let query: LoginQuery = from_str(params)?; + let mut data = match CLIENT + .login(query.username.clone(), query.password.clone()) + .await + { + Ok(data) => data, + Err(err) => { + println!("LOGIN ERROR : {:?}", err); + return Err(err); + } + }; + let mut lock = CONTEXT.lock().await; + lock.login = true; + lock.last_login = chrono::Local::now().timestamp(); + save_property("cookie".to_string(), CLIENT.cookie_str().await).await?; + drop(lock); + save_property("username".to_owned(), query.username.clone()).await?; + save_property("password".to_owned(), query.password).await?; + let lpp = load_property("last_login_username".to_owned()).await?; + save_property("last_login_username".to_owned(), query.username.to_string()).await?; + if !lpp.eq(&query.username) { + let _ = reload_pro().await; + } + if data.favorite_list.len() > 0 { + if let Ok(json) = to_string(&data.favorite_list) { + if let Ok(json) = to_string(&PerUserFavourFolder { + username: query.username.clone(), + ff_json: json, + }) { + let _ = save_property("fav_list".to_string(), json).await; + } + } + } else { + if let Ok(json) = load_property("fav_list".to_string()).await { + if json.len() > 0 { + if let Ok(ff) = from_str::(&json) { + if query.username.eq(&ff.username) { + if let Ok(ff) = from_str::>(&ff.ff_json) { + data.favorite_list = ff; + } + } + } + } + } + } + Ok(to_string(&data)?) +} + +async fn logout(_: &str) -> Result { + save_property("cookie".to_string(), "".to_owned()).await?; + save_property("username".to_owned(), "".to_owned()).await?; + save_property("password".to_owned(), "".to_owned()).await?; + save_property("last_login_username".to_owned(), "".to_owned()).await?; + save_property("fav_list".to_string(), "".to_owned()).await?; + Ok("".to_owned()) +} + +async fn categories() -> Result { + use_web_cache("CATEGORIES".to_string(), Duration::new(3600, 0), || async { + Ok(to_string(&CLIENT.categories().await?)?) + }) + .await +} + +const NO_PRO_MAX: i64 = 10; + +async fn comics(params: &str) -> Result { + let query: ComicsQuery = from_str(params)?; + let key = format!( + "COMICS:{}:{}:{}", + query.categories_slug.clone(), + query.sort_by.clone(), + query.page.clone(), + ); + if !is_pro().await?.is_pro && query.page > NO_PRO_MAX { + return Err(anyhow!("需要发电鸭")); + } + use_web_cache(key, Duration::new(3600, 0), || async { + Ok(to_string( + &CLIENT + .comics(query.categories_slug, query.sort_by, query.page) + .await?, + )?) + }) + .await +} + +async fn comic_search(params: &str) -> Result { + let query: ComicSearchQuery = from_str(params)?; + if !is_pro().await?.is_pro && query.page > NO_PRO_MAX { + return Err(anyhow!("需要发电鸭")); + } + save_search_history(query.search_query.clone()).await?; + let key = format!( + "COMIC_SEARCH:{}:{}:{}", + query.search_query.clone(), + query.sort_by.clone(), + query.page.clone(), + ); + use_web_cache(key, Duration::new(3600, 0), || async { + Ok(to_string( + &CLIENT + .comics_search(query.search_query, query.sort_by, query.page) + .await?, + )?) + }) + .await +} + +async fn clear_all_search_log() -> Result { + db_clear_all_search_log().await?; + Ok("".to_string()) +} + +async fn clear_a_search_log(content: &str) -> Result { + db_clear_a_search_log(content.to_string()).await?; + Ok("".to_string()) +} + +async fn album(params: &str) -> Result { + let comic_id = params.parse::()?; + let key = format!("ALBUM:{}", comic_id); + let data = use_web_cache(key, Duration::new(3600, 0), || async { + Ok(to_string(&CLIENT.album(comic_id).await?)?) + }) + .await?; + let model: ComicAlbumResponse = from_str(&data)?; + last_view_album(model).await?; + Ok(data) +} + +async fn chapter(params: &str) -> Result { + let comic_id = params.parse::()?; + let key = format!("CHAPTER:{}", comic_id); + use_web_cache(key, Duration::new(3600, 0), || async { + Ok(to_string(&CLIENT.chapter(comic_id).await?)?) + }) + .await +} + +async fn forum(params: &str) -> Result { + let query: ForumQuery = serde_json::from_str(params)?; + let key = format!( + "FORUM:{}:{}:{}", + if let Some(mode) = query.mode.clone() { + mode + } else { + "null".to_string() + }, + if let Some(aid) = query.aid.clone() { + aid.to_string() + } else { + "null".to_string() + }, + query.page + ); + use_web_cache(key, Duration::new(3600, 0), || async { + let mut comments = CLIENT.forum(query.mode, query.aid, query.page).await?; + for i in 0..comments.list.len() { + let tmp = comments.list[i] + .content + .trim_start_matches("
") + .trim_end_matches("
") + .to_string(); + comments.list[i].content = tmp; + } + Ok(to_string(&comments)?) + }) + .await +} + +async fn comment(params: &str) -> Result { + check_login().await?; + let query: CommentQuery = serde_json::from_str(params)?; + clean_web_cache_by_patten(format!("FORUM:%:{}:%", query.aid.clone())).await?; + clean_web_cache_by_patten(format!("CHAPTER:{}", query.aid.clone())).await?; + Ok(to_string(&CLIENT.comment(query.aid, query.comment).await?)?) +} + +async fn child_comment(params: &str) -> Result { + check_login().await?; + let query: ChildCommentQuery = serde_json::from_str(params)?; + clean_web_cache_by_patten(format!("FORUM:%:{}:%", query.aid.clone())).await?; + clean_web_cache_by_patten(format!("CHAPTER:{}", query.aid.clone())).await?; + Ok(to_string( + &CLIENT + .child_comment(query.aid, query.comment, query.comment_id) + .await?, + )?) +} + +async fn check_login() -> Result { + let time = chrono::Local::now().timestamp(); + let mut lock = CONTEXT.lock().await; + if !lock.login { + drop(lock); + return Err(anyhow!("请登录")); + } + if time - 600 > lock.last_login { + println!("need re login"); + // 调用login会重复加锁死锁 + let username = load_property("username".to_owned()).await?; + let password = load_property("password".to_owned()).await?; + let _ = CLIENT.login(username, password).await?; + println!("re login"); + lock.login = true; + lock.last_login = chrono::Local::now().timestamp(); + save_property("cookie".to_string(), CLIENT.cookie_str().await).await?; + drop(lock); + } + return Ok("".to_owned()); +} + +async fn favorites(params: &str) -> Result { + check_login().await?; + let query: FavoursQuery = from_str(params)?; + if !is_pro().await?.is_pro && query.page > NO_PRO_MAX { + return Err(anyhow!("需要发电鸭")); + } + let key = format!("FAVORITES:{}:{}:{}", query.folder_id, query.page, query.o); + use_web_cache(key, Duration::new(600, 0), || async { + Ok(to_string( + &CLIENT + .favorites(query.folder_id, query.page, query.o) + .await?, + )?) + }) + .await +} + +async fn set_favorite(params: &str) -> Result { + check_login().await?; + let aid = params.parse::()?; + let data = CLIENT.set_favorite(aid).await?; + clean_web_cache_by_patten("FAVORITES:%".to_owned()).await?; + clean_web_cache_by_patten(format!("ALBUM:{}", aid)).await?; + clean_web_cache_by_patten(format!("CHAPTER:{}", aid)).await?; + Ok(to_string(&data)?) +} + +async fn games(params: &str) -> Result { + check_login().await?; + let page = params.parse::()?; + let key = format!("GAMES:{}", page); + use_web_cache(key, Duration::new(3600, 0), || async { + Ok(to_string(&CLIENT.games(page).await?)?) + }) + .await +} + +async fn jm_3x4_cover(params: &str) -> Result { + let comic_id = params.parse::()?; + if let Some(path) = jm_3x4_cover_by_id(comic_id).await { + return Ok(path); + } + let key = format!("JM_3X4_COVER:{}", comic_id.clone()); + let url = CLIENT.comic_cover_url_3x4(comic_id).await; + + use_image_cache( + key, + Box::pin(download_image_from_url(&url, 0, String::default())), + ) + .await +} + +async fn jm_square_cover(params: &str) -> Result { + let comic_id = params.parse::()?; + if let Some(path) = jm_square_cover_by_id(comic_id).await { + return Ok(path); + } + let key = format!("JM_SQUARE_COVER:{}", comic_id.clone()); + let url = CLIENT.comic_cover_url_square(comic_id).await; + use_image_cache( + key, + Box::pin(download_image_from_url(&url, 0, String::default())), + ) + .await +} + +async fn jm_page_image(params: &str) -> Result { + let query: PageImageQuery = serde_json::from_str(params)?; + let key = page_image_key(query.id, &query.image_name); + if let Some(path) = page_image_by_key(&key).await { + return Ok(path); + } + let url = CLIENT + .comic_page_url(query.id.clone(), query.image_name.clone()) + .await; + use_image_cache( + key, + Box::pin(download_image_from_url(&url, query.id, query.image_name)), + ) + .await +} + +async fn jm_photo_image(params: &str) -> Result { + let key = format!("JM_PHOTO_IMAGE:{}", params,); + let url = CLIENT.photo_url(params.to_string()).await; + use_image_cache( + key, + Box::pin(download_image_from_url(&url, 0, String::default())), + ) + .await +} + +//let data = download_image_from_url(&url, page_image_flag, page_image_flag2).await?; + +async fn image_size(params: &str) -> Result { + let img = image::load_from_memory(std::fs::read(params)?.as_slice())?; + let w = img.width(); + let h = img.height(); + Ok(to_string(&ImageSize { w, h })?) +} + +async fn clean_all_cache() -> Result { + clean_all_image_cache().await?; + clean_all_web_cache().await?; + Ok(String::default()) +} + +async fn http_get(params: &str) -> Result { + Ok(reqwest::ClientBuilder::new() + .build() + .unwrap() + .get(params) + .header("User-Agent", "jasmine") + .send() + .await? + .text() + .await?) +} + +async fn last_search_histories(params: &str) -> Result { + let limit = params.parse::()?; + let history = load_last_search_histories(limit).await?; + Ok(to_string(&history)?) +} + +pub(crate) fn page_image_key(chapter_id: i64, image_name: &str) -> String { + format!("JM_PAGE_IMAGE:{}:{}", chapter_id, image_name,) +} + +/// page_image_flag : 如果是pageImage, 传chapterId, 否则传0 +/// page_image_flag2 : 如果是pageImage, 传name, 否则传"" +pub(crate) async fn download_image_from_url( + url: &str, + page_image_flag: i64, + page_image_flag2: String, +) -> Result<(bytes::Bytes, u32, u32)> { + let agent = CLIENT.agent.lock().await; + let req = agent.get(url); + drop(agent); + let data: bytes::Bytes = req + .header("user-agent", crate::define::UA.clone()) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + Ok(check_page_image_flag( + data, + page_image_flag, + page_image_flag2, + )?) +} + +fn check_page_image_flag( + data: bytes::Bytes, + page_image_flag: i64, + page_image_flag2: String, +) -> Result<(bytes::Bytes, u32, u32)> { + let src = image::load_from_memory(&data)?; + if page_image_flag <= 220980 { + return Ok((data, src.width(), src.height())); + } + let format = image::guess_format(&data)?.extensions_str()[0]; + if "gif".eq(format) { + let src = image::load_from_memory(&data)?; + return Ok((data, src.width(), src.height())); + } + let rows = if page_image_flag < 268850 { + 10 + } else { + let md5 = md5::compute(format!( + "{}{}", + page_image_flag, + page_image_flag2.split(".").nth(0).unwrap() + )); + let hex = hex::encode(md5.as_ref()); + let bytes = hex.as_bytes(); + let byte = bytes[bytes.len() - 1]; + if page_image_flag <= 421925 { + ((byte as i64) % 10) * 2 + 2 + } else { + ((byte as i64) % 8) * 2 + 2 + } + } as u32; + let height = src.height(); + let width = src.width(); + let remainder = height % rows; + let mut dst = image::ImageBuffer::new(width, height); + let mut copy_image = |src_start_x: u32, + src_start_y: u32, + dst_start_x: u32, + dst_start_y: u32, + width: u32, + height: u32| { + for y in 0..height { + for x in 0..width { + let pixel = src.get_pixel(src_start_x + x, src_start_y + y); + dst.put_pixel(dst_start_x + x, dst_start_y + y, pixel); + } + } + }; + for x in 0..rows { + let mut copy_h = height / rows; + let mut py = copy_h * (x); + let y = height - (copy_h * (x + 1)) - remainder; + if x == 0 { + copy_h += remainder + } else { + py += remainder + } + copy_image(0, y, 0, py, width, copy_h); + } + let pixels = dst.as_bytes(); + let mut file_buffer: Vec = vec![]; + PngEncoder::new(&mut file_buffer).write_image(pixels, width, height, ColorType::Rgba8)?; + Ok((bytes::Bytes::from(file_buffer), width, height)) +} + +async fn set_proxy(params: &str) -> Result { + let agent = if params.len() > 0 { + reqwest::ClientBuilder::new() + .proxy(reqwest::Proxy::all(params)?) + .timeout(Duration::new(30, 0)) + .build() + } else { + reqwest::ClientBuilder::new() + .timeout(Duration::new(30, 0)) + .build() + }?; + save_property("proxy_url".to_string(), params.to_owned()).await?; + CLIENT.set_agent(agent).await; + Ok("".to_owned()) +} + +async fn get_proxy() -> Result { + load_property("proxy_url".to_string()).await +} + +async fn ping_server(params: &str) -> Result { + let api_host = match params { + "0" => ApiHost::Default, + "1" => ApiHost::Branch1, + "2" => ApiHost::Branch2, + "3" => ApiHost::Branch3, + _ => return Err(anyhow!("不支持的分流")), + }; + let lock = CLIENT.agent.lock().await; + let agent = lock.clone(); + drop(lock); + let request = agent + .get(format!("https://{}/", api_host.as_str())) + .timeout(Duration::from_secs(10)); + let time1 = chrono::Local::now().timestamp_millis(); + let response = request.send().await?; + let status = response.status(); + let time2 = chrono::Local::now().timestamp_millis(); + let _ = response.text().await?; + if status.is_client_error() { + Ok(format!("{}", time2 - time1)) + } else { + Err(anyhow!("不支持的分流")) + } +} + +#[cfg(test)] +mod tests; + +#[no_mangle] +pub unsafe extern "C" fn load_int_property(name: *const c_char, default_value: i32) -> i32 { + let name = CStr::from_ptr(name).to_str().unwrap(); + let name = name.to_string(); + let result = RUNTIME.block_on(async move { load_property(name).await }); + match result { + Ok(value) => { + if value.is_empty() { + default_value + } else { + match value.parse::() { + Ok(value) => value, + Err(err) => { + println!("{:?}", err); + default_value + } + } + } + } + Err(err) => { + println!("{:?}", err); + default_value + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn save_int_property(name: *const c_char, value: i32) { + let name = CStr::from_ptr(name).to_str().unwrap(); + let name = name.to_string(); + let value = value.to_string(); + let _ = RUNTIME.block_on(async move { save_property(name, value).await }); +} + +fn get_home_dir() -> Result { + match std::env::var("HOME") { + Ok(var) => Ok(var), + Err(_) => Ok(String::default()), + } +} + +fn mkdirs(folder: &str) -> Result { + let dir = Path::new(folder); + if !dir.exists() { + std::fs::create_dir_all(dir)?; + } + Ok("".to_string()) +} + +async fn copy_picture_to_folder(query_str: &str) -> Result { + let query: SaveImage = serde_json::from_str(query_str)?; + let folder = Path::new(query.folder.as_str()); + if !folder.exists() { + tokio::fs::create_dir_all(folder).await?; + } + let buff = tokio::fs::read(query.path.as_str()).await?; + let ext = image::guess_format(&buff)?.extensions_str()[0]; + let file = folder.join(format!( + "{}{}.{}", + chrono::Utc::now().timestamp_micros(), + random::(), + ext + )); + tokio::fs::write(file, buff).await?; + Ok("".to_string()) +} + +async fn load_server_name() -> Result { + let sn = load_property("pro_server_name".to_string()).await?; + if sn.is_empty() { + let sn = "HK".to_string(); + Ok(sn) + } else { + Ok(sn) + } +} + +async fn set_pro_server_name(params: &str) -> Result { + save_property("pro_server_name".to_string(), params.to_owned()).await?; + Ok("".to_string()) +} + +async fn get_pro_server_name() -> Result { + load_server_name().await +} diff --git a/native/jmbackend/src/sync.rs b/native/jmbackend/src/sync.rs new file mode 100644 index 0000000..c88bc10 --- /dev/null +++ b/native/jmbackend/src/sync.rs @@ -0,0 +1,150 @@ +use crate::active_db::{view_log, view_log_tag, ACTIVE_DB}; +use crate::Result; +use crate::SyncWebdav; +use futures_util::TryStreamExt; +use grouping_by::GroupingBy; +use itertools::Itertools; +use sea_orm::ActiveValue::Set; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::QueryFilter; +use sea_orm::QueryOrder; +use sea_orm::TransactionTrait; +use sea_orm::{ActiveModelTrait, IntoActiveModel}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, to_string}; +use std::fmt::Write; +use std::ops::Deref; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio_util::io::StreamReader; + +pub(crate) async fn sync_webdav(json: &str) -> Result { + let config: SyncWebdav = from_str(json)?; + let client = reqwest::Client::new(); + // 向下同步 + let mut req = client.request(reqwest::Method::GET, config.url.clone()); + if !config.username.is_empty() && !config.password.is_empty() { + req = req.basic_auth(config.username.clone(), Some(config.password.clone())); + } + let rsp = req.send().await?; + match rsp.status().as_u16() { + 200 => { + let mut line = String::new(); + let mut lines = BufReader::new(StreamReader::new( + rsp.bytes_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)), + )); + loop { + if lines.read_line(&mut line).await? == 0 { + break; + } + let str = line.trim(); + if str.is_empty() { + continue; + } + let vl_info: ViewLogInfo = from_str(str)?; + upgrade(vl_info).await?; + line.clear(); + } + } + 404 => {} + _ => {} + } + // 向上同步 + let last = load_last_1000_info().await?; + let mut buf = bytes::BytesMut::new(); + for x in &last { + buf.write_str(&to_string(x)?)?; + buf.write_str("\n")?; + } + let mut req = client.request(reqwest::Method::PUT, config.url); + if !config.username.is_empty() && !config.password.is_empty() { + req = req.basic_auth(config.username, Some(config.password)); + } + let rsp = req.body(buf.to_vec()).send().await?; + rsp.error_for_status()?; + Ok("".to_owned()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, Hash, PartialEq)] +struct ViewLogInfo { + pub view_log: view_log::Model, + pub view_log_tags: Vec, +} + +async fn load_last_1000_info() -> Result> { + let db = ACTIVE_DB.get().unwrap().lock().await; + let logs: Vec = view_log::Entity::find() + .order_by_desc(view_log::Column::LastViewTime) + .all(db.deref()) + .await?; + let log_ids = logs.iter().map(|e| e.id).collect_vec(); + let tags: Vec = view_log_tag::Entity::find() + .filter(view_log_tag::Column::Id.is_in(log_ids)) + .all(db.deref()) + .await?; + let group = tags.iter().cloned().grouping_by(|t| t.id); + Ok(logs + .iter() + .map(|log| ViewLogInfo { + view_log: log.clone(), + view_log_tags: if let Some(s) = group.get(&log.id) { + s.clone() + } else { + vec![] + }, + }) + .collect_vec()) +} + +async fn upgrade(vl_info: ViewLogInfo) -> Result<()> { + let db = ACTIVE_DB.get().unwrap().lock().await; + let in_db = view_log::Entity::find_by_id(vl_info.view_log.id.clone()) + .one(db.deref()) + .await?; + if let Some(in_db) = in_db { + if in_db.last_view_time < vl_info.view_log.last_view_time { + db.transaction::<_, (), sea_orm::DbErr>(|txn| { + Box::pin(async move { + let in_db_view_log = in_db; + let mut in_db_view_log: view_log::ActiveModel = in_db_view_log.into(); + in_db_view_log.last_view_time = Set(vl_info.view_log.last_view_time); + in_db_view_log.last_view_chapter_id = + Set(vl_info.view_log.last_view_chapter_id); + in_db_view_log.last_view_page = Set(vl_info.view_log.last_view_page); + in_db_view_log.update(txn).await?; + Ok(()) + }) + }) + .await?; + } + } else { + db.transaction::<_, (), sea_orm::DbErr>(|txn| { + Box::pin(async move { + // 插入主体 + let in_db_view_log = view_log::Model { + id: vl_info.view_log.id.clone(), + author: vl_info.view_log.author, + description: vl_info.view_log.description, + name: vl_info.view_log.name, + last_view_time: vl_info.view_log.last_view_time, + last_view_chapter_id: vl_info.view_log.last_view_chapter_id, + last_view_page: vl_info.view_log.last_view_page, + }; + view_log::Entity::insert(in_db_view_log.into_active_model()) + .exec(txn) + .await?; + // 插入tag + for tag in vl_info.view_log_tags { + view_log_tag::Entity::insert(tag.into_active_model()) + .exec(txn) + .await?; + } + // ok + Ok(()) + }) + }) + .await?; + }; + Ok(()) +} diff --git a/native/jmbackend/src/tests.rs b/native/jmbackend/src/tests.rs new file mode 100644 index 0000000..2002ef1 --- /dev/null +++ b/native/jmbackend/src/tests.rs @@ -0,0 +1,2 @@ +#[test] +fn it_works() {} diff --git a/native/jmbackend/src/tools.rs b/native/jmbackend/src/tools.rs new file mode 100644 index 0000000..1edfd07 --- /dev/null +++ b/native/jmbackend/src/tools.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +pub(crate) fn join_paths(paths: Vec<&str>) -> String { + match paths.len() { + 0 => String::default(), + _ => { + let mut path: PathBuf = PathBuf::new(); + for x in 0..paths.len() { + path = path.join(paths[x]); + } + return path.to_str().unwrap().to_string(); + } + } +} diff --git a/native/jmbackend/src/types.rs b/native/jmbackend/src/types.rs new file mode 100644 index 0000000..0e08d86 --- /dev/null +++ b/native/jmbackend/src/types.rs @@ -0,0 +1,214 @@ +use jmcomic::{FavoritesOrder, SelfInfo, SortBy}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BackendContext { + pub login: bool, + pub last_login: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DartQuery { + pub method: String, + pub params: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ResponseToDart { + pub error_message: String, + pub response_data: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PreLoginResponse { + pub pre_set: bool, + pub pre_login: bool, + pub self_info: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LoginQuery { + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ComicsQuery { + pub categories_slug: String, + pub sort_by: SortBy, + pub page: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ComicSearchQuery { + pub search_query: String, + pub sort_by: SortBy, + pub page: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ForumQuery { + pub mode: Option, + pub aid: Option, + pub page: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CommentQuery { + pub aid: i64, + pub comment: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChildCommentQuery { + pub aid: i64, + pub comment: String, + pub comment_id: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SaveProperty { + pub k: String, + pub v: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PageImageQuery { + pub id: i64, + pub image_name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ImageSize { + pub w: u32, + pub h: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UpdateViewLogQuery { + pub id: i64, + pub last_view_chapter_id: i64, + pub last_view_page: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DownloadCreate { + pub album: DownloadCreateAlbum, + pub chapters: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DownloadCreateAlbum { + pub id: i64, + pub name: String, + pub author: Vec, + pub tags: Vec, + pub works: Vec, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DownloadCreateChapter { + pub id: i64, + pub name: String, + pub sort: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FavoursQuery { + pub folder_id: i64, + pub page: i64, + pub o: FavoritesOrder, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PerUserFavourFolder { + pub username: String, + pub ff_json: String, +} + +//////////////////////////// + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ExportQuery { + pub dir: String, + pub comic_id: Vec, + pub delete_exported: bool, +} + +macro_rules! enum_str { + ($name:ident { $($variant:ident($str:expr), )* }) => { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum $name { + $($variant,)* + } + + impl ::serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where S: ::serde::Serializer, + { + // 将枚举序列化为字符串。 + serializer.serialize_str(match *self { + $( $name::$variant => $str, )* + }) + } + } + + impl<'de> ::serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where D: ::serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> ::serde::de::Visitor<'de> for Visitor { + type Value = $name; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { + write!(formatter, "a string for {}", stringify!($name)) + } + + fn visit_str(self, value: &str) -> Result<$name, E> + where E: ::serde::de::Error, + { + match value { + $( $str => Ok($name::$variant), )* + _ => Err(E::invalid_value(::serde::de::Unexpected::Other( + &format!("unknown {} variant: {}", stringify!($name), value) + ), &self)), + } + } + } + + // 从字符串反序列化枚举。 + deserializer.deserialize_str(Visitor) + } + } + } +} + +enum_str!(SyncDirection { + Merge("Merge"), +}); + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SyncWebdav { + pub url: String, + pub username: String, + pub password: String, + pub direction: SyncDirection, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ExportSingleQuery { + pub id: i64, + pub folder: String, + pub rename: Option, + pub delete_exported: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SaveImage { + pub folder: String, + pub path: String, +} diff --git a/native/jmcomic-rs/.gitignore b/native/jmcomic-rs/.gitignore new file mode 100644 index 0000000..4262d78 --- /dev/null +++ b/native/jmcomic-rs/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock + +/.idea/ +*.iml +/data.txt diff --git a/native/jmcomic-rs/Cargo.toml b/native/jmcomic-rs/Cargo.toml new file mode 100644 index 0000000..b9e3d04 --- /dev/null +++ b/native/jmcomic-rs/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "jmcomic" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.75" +base64 = "0.13.0" +block-modes = "0.8.1" +aes = "0.7.5" +hex = "0.4.3" +lazy_static = "1.4.0" +md5 = "0.7.0" +rand = "0.8.5" +reqwest = { version = "0.11.22", features = ["tokio-rustls", "rustls", "rustls-tls"], default-features = false } +serde = { version = "1.0.190", features = ["derive"] } +serde_derive = "1.0.190" +serde_json = "1.0.108" +serde_path_to_error = "0.1.14" +tokio = { version = "1.33.0", features = ["macros"] } diff --git a/native/jmcomic-rs/src/entities.rs b/native/jmcomic-rs/src/entities.rs new file mode 100644 index 0000000..d58e7de --- /dev/null +++ b/native/jmcomic-rs/src/entities.rs @@ -0,0 +1,497 @@ +use serde_derive::Deserialize; +use serde_derive::Serialize; +use std::fmt::{Display, Formatter}; +use std::num::ParseIntError; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategoryData { + pub categories: Vec, + pub blocks: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Category { + #[serde(deserialize_with = "fuzzy_i64")] + pub id: i64, + pub name: String, + pub slug: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub total_albums: i64, + #[serde(rename = "type")] + pub type_field: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Block { + pub title: String, + pub content: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SearchPage { + pub search_query: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub total: i64, + pub content: Vec, + #[serde(deserialize_with = "fuzzy_option_i64", default = "default_option_i64")] + pub redirect_aid: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComicSimple { + #[serde(deserialize_with = "fuzzy_i64")] + pub id: i64, + pub author: String, + #[serde(deserialize_with = "null_string")] + pub description: String, + pub name: String, + pub image: String, + pub category: CategorySimple, + pub category_sub: CategorySimple, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CategorySimple { + pub id: Option, + pub title: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComicAlbumResponse { + pub id: i64, + pub name: String, + pub author: Vec, + pub images: Vec, + #[serde(deserialize_with = "null_string")] + pub description: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub total_views: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub likes: i64, + pub series: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub series_id: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub comment_total: i64, + pub tags: Vec, + pub works: Vec, + // pub actors: Vec, + pub related_list: Vec, + pub liked: bool, + pub is_favorite: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComicChapterResponse { + #[serde(deserialize_with = "fuzzy_i64")] + pub id: i64, + pub series: Vec, + pub tags: String, + pub name: String, + pub images: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub series_id: i64, + pub is_favorite: bool, + pub liked: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Series { + #[serde(deserialize_with = "fuzzy_i64")] + pub id: i64, + pub name: String, + pub sort: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RelatedList { + #[serde(deserialize_with = "fuzzy_i64")] + pub id: i64, + pub author: String, + #[serde(deserialize_with = "null_string")] + pub description: String, + pub name: String, + pub image: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Page { + pub list: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub total: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CountPage { + pub list: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub total: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub count: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VideoSimple { + pub id: String, + pub photo: String, + pub title: String, + pub tags: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Comment { + // 仅主要评论 + #[serde( + deserialize_with = "fuzzy_i64", + default = "default_i64", + rename = "AID" + )] + pub aid: i64, + // 仅主要评论 + // #[serde(rename = "BID")] + // pub bid: Value, + #[serde(deserialize_with = "fuzzy_i64", rename = "CID")] + pub cid: i64, + #[serde(deserialize_with = "fuzzy_i64", rename = "UID")] + pub uid: i64, + pub username: String, + pub nickname: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub likes: i64, + pub gender: String, + pub update_at: String, + pub addtime: String, + #[serde(deserialize_with = "fuzzy_i64", rename = "parent_CID")] + pub parent_cid: i64, + pub expinfo: Expinfo, + // 仅主要评论, 文章名字 + #[serde(default = "default_string")] + pub name: String, + pub content: String, + pub photo: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub spoiler: i64, + // 仅主要评论, 还是错别字 + #[serde(default = "default_vec")] + pub replys: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Expinfo { + pub level_name: String, + pub level: i64, + #[serde(rename = "nextLevelExp")] + pub next_level_exp: i64, + pub exp: String, + #[serde(rename = "expPercent")] + pub exp_percent: f64, + #[serde(deserialize_with = "fuzzy_i64")] + pub uid: i64, + pub badges: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Badge { + pub content: String, + pub name: String, + pub id: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SelfInfo { + #[serde(deserialize_with = "fuzzy_i64")] + pub uid: i64, + pub username: String, + pub email: String, + pub emailverified: String, + pub photo: String, + pub fname: String, + pub gender: String, + #[serde(deserialize_with = "null_string")] + pub message: String, + #[serde(deserialize_with = "fuzzy_i64")] + pub coin: i64, + pub album_favorites: i64, + pub s: String, + #[serde(default = "default_vec")] + pub favorite_list: Vec, + pub level_name: String, + pub level: i64, + #[serde(rename = "nextLevelExp")] + pub next_level_exp: i64, + pub exp: String, + #[serde(rename = "expPercent")] + pub exp_percent: f64, + pub badges: Vec, + pub album_favorites_max: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FavoriteFolder { + #[serde(rename = "0")] + pub n0: String, + #[serde(rename = "FID")] + pub fid: String, + #[serde(rename = "1")] + pub n1: String, + #[serde(rename = "UID")] + pub uid: String, + #[serde(rename = "2")] + pub n2: String, + pub name: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionResponse { + pub status: String, + pub msg: String, + #[serde(rename = "type")] + pub action_type: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommentResponse { + #[serde(deserialize_with = "null_string")] + pub msg: String, + pub status: String, + pub aid: i64, + pub cid: i64, + pub spoiler: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GamePage { + pub games: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub games_total: i64, + pub categories: Vec, + pub hot_games: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Game { + #[serde(deserialize_with = "fuzzy_i64")] + pub gid: i64, + pub title: String, + pub description: String, + pub tags: String, + pub link: String, + pub link_title: String, + pub photo: String, + #[serde(rename = "type")] + pub game_type: Vec, + pub categories: GameCategory, + #[serde(deserialize_with = "fuzzy_i64")] + pub update_at: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub total_clicks: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub order_rank: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub status: i64, + pub show_lang: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GameCategory { + #[serde(deserialize_with = "null_string")] + pub name: String, + #[serde(deserialize_with = "null_string")] + pub slug: String, +} + +///////////////////// + +macro_rules! enum_str { + ($name:ident { $($variant:ident($str:expr), )* }) => { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum $name { + $($variant,)* + } + + impl $name { + pub fn as_str(&self) -> &'static str { + match self { + $( $name::$variant => $str, )* + } + } + } + + impl Display for $name { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + $( $name::$variant => write!(f,"{}",$str), )* + } + } + } + + impl ::serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where S: ::serde::Serializer, + { + // 将枚举序列化为字符串。 + serializer.serialize_str(match *self { + $( $name::$variant => $str, )* + }) + } + } + + impl<'de> ::serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where D: ::serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> ::serde::de::Visitor<'de> for Visitor { + type Value = $name; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { + write!(formatter, "a string for {}", stringify!($name)) + } + + fn visit_str(self, value: &str) -> Result<$name, E> + where E: ::serde::de::Error, + { + match value { + $( $str => Ok($name::$variant), )* + _ => Err(E::invalid_value(::serde::de::Unexpected::Other( + &format!("unknown {} variant: {}", stringify!($name), value) + ), &self)), + } + } + } + + // 从字符串反序列化枚举。 + deserializer.deserialize_str(Visitor) + } + } + } +} + +enum_str!(SortBy { + Default(""), + New("mr"), + Favourite("tf"), + View("mv"), + ViewDay("mv_t"), + ViewWeek("mv_w"), + ViewMonth("mv_m"), +}); + +// enum_str!(ApiHost { +// Default("www.jmapinode.biz"), +// Branch1("www.jmapinode.top"), +// Branch2("www.jmapinode2.top"), +// Branch3("www.jmapinode3.top"), +// }); + +enum_str!(ApiHost { + Default("www.asjmapihost.cc"), + Branch1("www.jmapinode1.top"), + Branch2("www.jmapinode2.top"), + Branch3("www.jmapinode3.top"), +}); + +enum_str!(CdnHost { + Proxy1("cdn-msp.jmapiproxy1.monster"), + Proxy2("cdn-msp2.jmapiproxy1.monster"), +}); + +enum_str!(FavoritesOrder { + Mr("mr"), + Mp("mp"), +}); + +enum_str!(ActionStatus { + Ok("ok"), + Fail("fail"), +}); + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JmActionResponse { + pub status: ActionStatus, + pub msg: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FFPage { + pub list: Vec, + #[serde(deserialize_with = "fuzzy_i64")] + pub total: i64, + #[serde(deserialize_with = "fuzzy_i64")] + pub count: i64, + pub folder_list: Vec, +} + +//////////////// + +fn null_string<'de, D>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let value: serde_json::Value = serde::Deserialize::deserialize(d)?; + if value.is_null() { + Ok(String::default()) + } else if value.is_string() { + Ok(value.as_str().unwrap().to_string()) + } else { + Err(serde::de::Error::custom("type error")) + } +} + +fn fuzzy_i64<'de, D>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let value: serde_json::Value = serde::Deserialize::deserialize(d)?; + if value.is_i64() { + Ok(value.as_i64().unwrap()) + } else if value.is_string() { + let str = value.as_str().unwrap(); + let from: std::result::Result = std::str::FromStr::from_str(str); + match from { + Ok(from) => Ok(from), + Err(_) => Err(serde::de::Error::custom("parse error")), + } + } else { + Err(serde::de::Error::custom("type error")) + } +} + +fn fuzzy_option_i64<'de, D>(d: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: serde_json::Value = serde::Deserialize::deserialize(d)?; + if value.is_null() { + Ok(None) + } else if value.is_i64() { + Ok(Some(value.as_i64().unwrap())) + } else if value.is_string() { + let str = value.as_str().unwrap(); + let from: std::result::Result = std::str::FromStr::from_str(str); + match from { + Ok(from) => Ok(Some(from)), + Err(_) => Err(serde::de::Error::custom("parse error")), + } + } else { + Err(serde::de::Error::custom("type error")) + } +} + +fn default_string() -> String { + String::default() +} + +fn default_i64() -> i64 { + 0 +} + +fn default_option_i64() -> Option { + None +} + +fn default_vec() -> Vec { + vec![] +} diff --git a/native/jmcomic-rs/src/lib.rs b/native/jmcomic-rs/src/lib.rs new file mode 100644 index 0000000..22fa1b1 --- /dev/null +++ b/native/jmcomic-rs/src/lib.rs @@ -0,0 +1,615 @@ +use std::collections::HashMap; +use std::ops::Deref; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub use anyhow::Result; +pub use entities::*; +use rand::prelude::SliceRandom; +use reqwest::Method; +use serde_json::{json, to_string, Value}; +use tokio::sync::Mutex; +use tools::*; +use rand::Rng; + +mod entities; +#[cfg(test)] +mod tests; +mod tools; + +const APP_VERSION: &'static str = "1.6.1"; +const USER_AGENT: &'static str = "Mozilla/5.0 (Linux; Android 13; 8d41w854d Build/TQ1A.230205.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/114.0.5735.196 Safari/537.36"; +const APP_KEY: &'static str = "0b931a6f4b5ccc3f8d870839d07ae7b2"; +// FINAL_KEY 由JS函数 encodeKey(magic+salt) 计算而来 +const APP_SALT: &'static str = "18comicAPP"; +const APP_CONTENT_SALT: &'static str = "18comicAPPContent"; + +pub struct Client { + api_host: Mutex>, + cdn_host: Mutex>, + pub cookie: Mutex>, + pub agent: Mutex, + pub user_agent: Mutex, +} + +impl Client { + + pub fn new() -> Self { + Self { + api_host: Mutex::new(None), + cdn_host: Mutex::new(None), + cookie: Mutex::new(HashMap::::new()), + agent: Mutex::new( + reqwest::ClientBuilder::new() + .timeout(Duration::new(30, 0)) + .build() + .unwrap(), + ), + user_agent: Mutex::new(USER_AGENT.to_string()), + } + } + + pub async fn set_agent(&self, agent: reqwest::Client) { + let mut lock = self.agent.lock().await; + *lock = agent; + } + + pub async fn set_user_agent(&self, user_agent: String) { + let mut lock = self.user_agent.lock().await; + *lock = user_agent; + } + + pub async fn init_cookie(&self) -> anyhow::Result { + self.request_data(Method::GET, "setting", json!({})).await?; + // self.request_data(Method::GET, "browser_setting", json!({})) + // .await?; + Ok(self.cookie_str().await) + } + + pub async fn set_cookie(&self, cookie: &str) -> Result<()> { + self.http_cookie(&cookie.replace(";", "\n").trim()).await?; + Ok(()) + } + + pub async fn cookie_str(&self) -> String { + let lock = self.cookie.lock().await; + let mut cookies = Vec::::new(); + for (k, v) in lock.deref() { + cookies.push(format!("{}={};", k, v)); + } + let cstr = cookies.join(""); + return cstr; + } + + pub async fn set_api_host(&self, api_host: T) + where + T: Into>, + { + *(self.api_host.lock().await) = api_host.into(); + } + + pub async fn get_api_host(&self) -> Option { + self.api_host.lock().await.clone() + } + + pub async fn set_cdn_host(&self, cdn_host: T) + where + T: Into>, + { + *(self.cdn_host.lock().await) = cdn_host.into(); + } + + pub async fn get_cdn_host(&self) -> Option { + self.cdn_host.lock().await.clone() + } + + fn random_api_host(&self) -> ApiHost { + let vec = vec![ + ApiHost::Default, + ApiHost::Branch1, + ApiHost::Branch2, + ApiHost::Branch3, + ]; + vec.choose(&mut rand::thread_rng()).unwrap().clone() + } + + fn random_cdn_host(&self) -> CdnHost { + let vec = vec![CdnHost::Proxy1, CdnHost::Proxy2]; + vec.choose(&mut rand::thread_rng()).unwrap().clone() + } + + async fn api_host_string(&self) -> String { + format!( + "{}", + if let Some(api) = self.api_host.lock().await.clone() { + api + } else { + self.random_api_host() + } + ) + } + + async fn cdn_host_string(&self) -> String { + format!( + "{}", + if let Some(api) = self.cdn_host.lock().await.clone() { + api + } else { + self.random_cdn_host() + } + ) + } + + async fn http_cookie(&self, set_cookie_str: &str) -> Result<()> { + let set_cookie_str: Vec<&str> = set_cookie_str + .split("\n") + .map(|str| str.trim()) + .filter(|str| !str.is_empty()) + .collect(); + for set_cookie in set_cookie_str { + let set_cookie = set_cookie.split(";").nth(0).unwrap_or(""); + let set_cookie: Vec<&str> = set_cookie.split("=").collect(); + if set_cookie.len() == 2 { + let mut lock = self.cookie.lock().await; + lock.insert( + set_cookie.get(0).unwrap().to_string(), + set_cookie.get(1).unwrap().to_string(), + ); + drop(lock); + } + } + Ok(()) + } + + pub async fn request_data(&self, method: Method, path: &str, query: Value) -> Result { + let mut obj = query.as_object().unwrap().clone(); + obj.insert("key".to_string(), Value::from(APP_KEY)); + obj.insert("view_mode_debug".to_string(), Value::from("1")); + obj.insert("view_mode".to_string(), Value::from("null")); + let agent = self.agent.lock().await; + let request = agent.request( + method.clone(), + format!("https://{}/{}", &self.api_host_string().await, path), + ); + drop(agent); + let time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let token_param = format!("{},{}", &time, &APP_VERSION); + let token = hex::encode(md5::compute(format!("{}{}", time, APP_SALT)).0); + let decode_key = hex::encode(md5::compute(format!("{}{}", time, APP_CONTENT_SALT)).0); + // 18comicAPPContent + let request = request.header("Tokenparam", token_param); + let request = request.header("token", token); + let request = request.header("cookie", &self.cookie_str().await); + let request = request.header("User-Agent", self.current_user_agent().await); + let request = request.header("Sec-Fetch-Site", "same-origin"); + let request = request.header("Accept-Language", "zh-CN,zh-Hans;q=0.9"); + let request = request.header("Sec-Fetch-Mode", "cors"); + let request = request.header("Content-Type", "application/json"); + let request = request.header("Origin", "null"); + let request = request.header("Sec-Fetch-Dest", "empty"); + let request = match method { + Method::GET => request.query(&obj), + _ => request.form(&obj), + }; + let response = request.send().await?; + if path == "setting" || path == "login" { + for x in response.headers() { + if x.0 == "set-cookie" { + self.http_cookie(std::str::from_utf8(x.1.as_bytes())?) + .await?; + } + } + } + let text = response.text().await?; + let json: Value = from_str(&text)?; + let code = json.get("code"); + match code { + None => return Err(anyhow::anyhow!("error response")), + Some(code) => { + if code.is_i64() { + match code.as_i64().unwrap() { + 200 => {} + _ => { + return match json.get("errorMsg") { + None => Err(anyhow::anyhow!("unknown error")), + Some(msg) => { + if msg.is_string() { + Err(anyhow::anyhow!(msg.as_str().unwrap().to_string())) + } else { + Err(anyhow::anyhow!("unknown error")) + } + } + }; + } + } + } else { + return Err(anyhow::anyhow!("code not is number")); + } + } + } + if path == "browser_setting" { + return Ok(to_string(&json.get("data"))?); + } + match json.get("data") { + None => Err(anyhow::anyhow!("data error 2")), + Some(data) => { + if data.is_string() { + let data = data.as_str().unwrap(); + let data = tools::decrypt_jm(data, decode_key.as_bytes())?; + Ok(data) + } else { + Err(anyhow::anyhow!("data error 3")) + } + } + } + } + + async fn request serde::Deserialize<'de>>( + &self, + method: Method, + path: &str, + query: Value, + ) -> Result { + let response = self.request_data(method, path, query).await?; + Ok(from_str(&response)?) + } + + pub async fn login(&self, username: String, password: String) -> Result { + Ok(self + .request( + Method::POST, + "login", + json!({ + "username": username, + "password": password, + }), + ) + .await?) + } + + pub async fn categories(&self) -> Result { + Ok(self.request(Method::GET, "categories", json!({})).await?) + } + + pub async fn latest(&self) -> Result> { + Ok(self.request(Method::GET, "latest", json!({})).await?) + } + + pub async fn comics( + &self, + categories_slug: String, + sort_by: SortBy, + page: i64, + ) -> Result> { + Ok(self + .request( + Method::GET, + "categories/filter", + json!({ + "page": page, + "order": "", + "c": categories_slug, + "o": sort_by, + }), + ) + .await?) + } + + pub async fn comics_search( + &self, + search_query: String, + sort_by: SortBy, + page: i64, + ) -> Result> { + Ok(self + .request( + Method::GET, + "search", + json!({ + "page":page, + "search_query": search_query, + "o":sort_by, + }), + ) + .await?) + } + + pub async fn album(&self, id: i64) -> Result { + Ok(self + .request( + Method::GET, + "album", + json!({ + "comicName":"", + "id":id, + }), + ) + .await?) + } + + pub async fn chapter(&self, id: i64) -> Result { + Ok(self + .request( + Method::GET, + "chapter", + json!({ + "comicName":"", + "id":id, + }), + ) + .await?) + } + + pub async fn comment(&self, aid: i64, comment: String) -> Result { + Ok(self + .request( + Method::POST, + "comment", + json!({ + "comment":comment, + "aid":aid, + }), + ) + .await?) + } + + pub async fn child_comment( + &self, + aid: i64, + comment: String, + comment_id: i64, + ) -> Result { + Ok(self + .request( + Method::POST, + "comment", + json!({ + "comment":comment, + "comment_id":comment_id, + "aid":aid, + }), + ) + .await?) + } + + pub async fn comic_cover_url_3x4(&self, comic_id: i64) -> String { + format!( + "https://{}/media/albums/{}_3x4.jpg", + self.cdn_host_string().await, + comic_id + ) + } + + pub async fn comic_cover_url_square(&self, comic_id: i64) -> String { + format!( + "https://{}/media/albums/{}.jpg", + self.cdn_host_string().await, + comic_id + ) + } + + pub async fn comic_page_url(&self, id: i64, name: String) -> String { + format!( + "https://{}/media/photos/{}/{}?v=", + self.cdn_host_string().await, + id, + name, + ) + } + + // cnd host or api host all allow + pub async fn photo_url(&self, photo_name: String) -> String { + format!( + "https://{}/media/users/{}", + self.cdn_host_string().await, + photo_name + ) + } + + pub async fn videos(&self, sort_by: SortBy, page: i64) -> Result> { + Ok(self + .request( + Method::GET, + "videos", + json!({ + "o": sort_by, + "page": page, + }), + ) + .await?) + } + + // 评论 + pub async fn forum( + &self, + mode: Option, + aid: Option, + page: i64, + ) -> Result> { + if let Some(mode) = mode { + if let Some(aid) = aid { + return self + .request( + Method::GET, + "forum", + json!({ + "mode": mode, + "aid": aid, + "page": page, + }), + ) + .await; + } + return self + .request( + Method::GET, + "forum", + json!({ + "mode": mode, + "page": page, + }), + ) + .await; + } + self.request( + Method::GET, + "forum", + json!({ + "page": page, + }), + ) + .await + } + + pub async fn set_favorite(&self, aid: i64) -> Result { + Ok(self + .request( + Method::POST, + "favorite", + json!({ + "aid": aid, + }), + ) + .await?) + } + + pub async fn favorites( + &self, + folder_id: i64, + page: i64, + o: FavoritesOrder, + ) -> Result> { + Ok(self + .request( + Method::GET, + "favorite", + json!({ + "folder_id": folder_id, + "page": page, + "o": o, + }), + ) + .await?) + } + + pub async fn favorites_intro(&self) -> Result { + Ok(self + .request( + Method::GET, + "favorite", + json!({ + "folder_id": 0, + "o": FavoritesOrder::Mr, + }), + ) + .await?) + } + + pub async fn create_favorite_folder(&self, name: String) -> Result<()> { + let rsp: JmActionResponse = self + .request( + Method::POST, + "favorite_folder", + json!({ + "type":"add", + "folder_name":name, + }), + ) + .await?; + match rsp.status { + ActionStatus::Ok => Ok(()), + ActionStatus::Fail => Err(anyhow::Error::msg(rsp.msg)), + } + } + + pub async fn delete_favorite_folder(&self, folder_id: i64) -> Result<()> { + let rsp: JmActionResponse = self + .request( + Method::POST, + "favorite_folder", + json!({ + "type":"delete", + "folder_id":folder_id, + }), + ) + .await?; + match rsp.status { + ActionStatus::Ok => Ok(()), + ActionStatus::Fail => Err(anyhow::Error::msg(rsp.msg)), + } + } + + pub async fn comic_favorite_folder_move(&self, comic_id: i64, folder_id: i64) -> Result<()> { + let rsp: JmActionResponse = self + .request( + Method::POST, + "favorite_folder", + json!({ + "type":"move", + "folder_id":folder_id, + "aid":comic_id, + }), + ) + .await?; + match rsp.status { + ActionStatus::Ok => Ok(()), + ActionStatus::Fail => Err(anyhow::Error::msg(rsp.msg)), + } + } + + pub async fn games(&self, page: i64) -> Result { + Ok(self + .request( + Method::GET, + "games", + json!({ + "page": page, + }), + ) + .await?) + } + + pub async fn watch_list(&self, page: i64) -> Result> { + Ok(self + .request( + Method::GET, + "watch_list", + json!({ + "page": page, + }), + ) + .await?) + } + + pub async fn current_user_agent(&self) -> String { + let lock = self.user_agent.lock().await; + lock.clone() + } + + pub fn rand_user_agent() -> String { + // mobile version + let adnroid_version = rand::thread_rng().gen_range(11..=15); + // webkit version + // rand a str [0-9a0z]{9} like 8d41w854d + let boot_id = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(9) + .map(char::from) + .collect::(); + // random from TQ1A / TQ2A ... TQ9A ... TQ1Z + let mobile_model = format!( + "TQ{}{}", + rand::thread_rng().gen_range(0..=9), + rand::thread_rng().gen_range('A'..='Z'), + ); + // random 230205 - 330205 + let build_version = rand::thread_rng().gen_range(230205..=330205); + let build_version2= rand::thread_rng().gen_range(1..=9); + // + format!( + "Mozilla/5.0 (Linux; Android {adnroid_version}; {boot_id} Build/{mobile_model}.{build_version}.00{build_version2}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/114.0.5735.196 Safari/537.36", + ) + } +} diff --git a/native/jmcomic-rs/src/tests.rs b/native/jmcomic-rs/src/tests.rs new file mode 100644 index 0000000..1994665 --- /dev/null +++ b/native/jmcomic-rs/src/tests.rs @@ -0,0 +1,132 @@ +use serde_json::json; + +use crate::{Client, FavoritesOrder, SortBy}; +use crate::{Method, Result}; + +/// 打印结果 +fn print(result: Result) { + match result { + Ok(data) => println!("{}", serde_json::to_string(&data).unwrap()), + Err(err) => panic!("{}", err), + } +} + +async fn client() -> Client { + let client = Client::new(); + client.set_user_agent(Client::rand_user_agent()).await; + client +} + +#[tokio::test] +async fn request_data() { + let rsp = client().await + .request_data(Method::GET, "setting", json!({})) + .await; + match rsp { + Ok(text) => println!("{}", text), + Err(err) => panic!("{}", err), + } +} + +async fn login_client() -> Client { + let client = client().await; + client + .login("username".to_owned(), "password".to_owned()) + .await + .unwrap(); + client +} + +#[tokio::test] +async fn login_request_data() { + match login_client() + .await + .request_data(Method::GET, "favorite", json!({"page":1})) + .await + { + Ok(text) => println!("{}", text), + Err(err) => panic!("{}", err), + } +} + +#[tokio::test] +async fn categories() { + print(client().await.categories().await) +} + +#[tokio::test] +async fn latest() { + print(client().await.latest().await) +} + +#[tokio::test] +async fn comics() { + print(client().await.comics("".to_string(), SortBy::Default, 1).await) +} + +#[tokio::test] +async fn album() { + print(client().await.album(215435).await) +} + +#[tokio::test] +async fn chapter() { + print(client().await.chapter(215435).await) +} + +#[tokio::test] +async fn videos() { + print(client().await.videos(SortBy::View, 2).await) +} + +#[tokio::test] +async fn forum() { + print(client().await.forum(None, None, 100).await) +} + +#[tokio::test] +async fn set_favorite() { + print(login_client().await.set_favorite(302608).await); +} + +#[tokio::test] +async fn favorites() { + print( + login_client() + .await + .favorites(0, 1, FavoritesOrder::Mp) + .await, + ); +} + +#[tokio::test] +async fn favorites_intro() { + print(login_client().await.favorites_intro().await); +} + +#[tokio::test] +async fn create_favorite_folder() { + print( + login_client() + .await + .create_favorite_folder("MY_FOLDER".to_string()) + .await, + ); +} + +#[tokio::test] +async fn games() { + print(client().await.games(1).await); +} + +#[tokio::test] +async fn comics_search() { + print( + client().await + .comics_search("ABC".to_owned(), SortBy::Default, 1) + .await, + ); +} + +#[tokio::test] +async fn test() {} diff --git a/native/jmcomic-rs/src/tools.rs b/native/jmcomic-rs/src/tools.rs new file mode 100644 index 0000000..7883d94 --- /dev/null +++ b/native/jmcomic-rs/src/tools.rs @@ -0,0 +1,23 @@ +use aes::Aes256; +use block_modes::block_padding::Pkcs7; +use block_modes::{BlockMode, Ecb}; + +type AesEcb = Ecb; + +pub(crate) fn decrypt_jm(data: &str, key: &[u8]) -> anyhow::Result { + let data = base64::decode(data)?; + let data = aes_decrypt_ecb(data, key)?; + let data = std::str::from_utf8(data.as_slice())?; + Ok(data.to_string()) +} + +fn aes_decrypt_ecb(data: Vec, key: &[u8]) -> anyhow::Result> { + Ok(AesEcb::new_from_slices(key, "".as_bytes())?.decrypt_vec(data.as_slice())?) +} + +/// FROM STRING 并打印出错的位置 +pub fn from_str serde::Deserialize<'de>>(json: &str) -> anyhow::Result { + Ok(serde_path_to_error::deserialize( + &mut serde_json::Deserializer::from_str(json), + )?) +}