diff --git a/sources/es.manhwaweb/.cargo/config.toml b/sources/es.manhwaweb/.cargo/config.toml new file mode 100644 index 000000000..f137b5a99 --- /dev/null +++ b/sources/es.manhwaweb/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +runner = "aidoku-test-runner" diff --git a/sources/es.manhwaweb/Cargo.lock b/sources/es.manhwaweb/Cargo.lock new file mode 100644 index 000000000..276d462eb --- /dev/null +++ b/sources/es.manhwaweb/Cargo.lock @@ -0,0 +1,355 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aidoku" +version = "0.3.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#c5a7e53d0c586a06056c76854017ccb220328193" +dependencies = [ + "euclid", + "hashbrown", + "itoa", + "num-traits", + "paste", + "postcard", + "serde", + "serde_json", + "talc", + "thiserror", +] + +[[package]] +name = "aidoku-test" +version = "1.0.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#c5a7e53d0c586a06056c76854017ccb220328193" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "manhwaweb" +version = "1.0.0" +dependencies = [ + "aidoku", + "aidoku-test", + "serde", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "talc" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "zmij" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" diff --git a/sources/es.manhwaweb/Cargo.toml b/sources/es.manhwaweb/Cargo.toml new file mode 100644 index 000000000..c59e04516 --- /dev/null +++ b/sources/es.manhwaweb/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "manhwaweb" +version = "1.0.0" +edition = "2024" + +[dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", version = "0.3.0", features = ["json"] } +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } + +[dev-dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", features = ["test"] } +aidoku-test = { git = "https://github.com/Aidoku/aidoku-rs.git" } + + +[lib] +crate-type = ["cdylib"] + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" +opt-level = "s" +strip = true +lto = true diff --git a/sources/es.manhwaweb/res/filters.json b/sources/es.manhwaweb/res/filters.json new file mode 100644 index 000000000..65308339c --- /dev/null +++ b/sources/es.manhwaweb/res/filters.json @@ -0,0 +1,151 @@ +[ + { + "type": "sort", + "title": "Ordenar por", + "id": "sortBy", + "options": [ + "Alfabetico", + "Creacion", + "Num. Capitulos" + ] + }, + { + "type": "select", + "title": "Tipo", + "id": "tipo", + "options": [ + "Ver todo", + "Manhwa", + "Manga", + "Manhua", + "Doujinshi", + "Novela", + "One shot" + ], + "ids": [ + "", + "manhwa", + "manga", + "manhua", + "doujinshi", + "novela", + "one_shot" + ] + }, + { + "type": "select", + "title": "Demografia", + "id": "demografia", + "options": [ + "Ver todo", + "Seinen", + "Shonen", + "Josei", + "Shojo" + ], + "ids": [ + "", + "seinen", + "shonen", + "josei", + "shojo" + ] + }, + { + "type": "select", + "title": "Estado", + "id": "estado", + "options": [ + "Ver todo", + "Publicandose", + "Pausado", + "Finalizado" + ], + "ids": [ + "", + "publicandose", + "pausado", + "finalizado" + ] + }, + { + "type": "select", + "title": "Erotico", + "id": "erotico", + "options": [ + "No", + "Si", + "Ver todo" + ], + "ids": [ + "no", + "si", + "" + ] + }, + { + "type": "multi-select", + "title": "Generos", + "id": "genres", + "options": [ + "Accion", + "Aventura", + "Comedia", + "Drama", + "Recuentos de la vida", + "Romance", + "Venganza", + "Harem", + "Fantasia", + "Sobrenatural", + "Tragedia", + "Psicologico", + "Horror", + "Thriller", + "Historias cortas", + "Ecchi", + "Gore", + "Girls love", + "Boys love", + "Reencarnacion", + "Sistema de niveles", + "Ciencia ficcion", + "Apocaliptico", + "Artes marciales", + "Superpoderes", + "Cultivacion (cultivo)", + "Milf" + ], + "ids": [ + "3", + "29", + "18", + "1", + "42", + "2", + "5", + "6", + "23", + "31", + "25", + "43", + "32", + "44", + "28", + "30", + "34", + "37", + "27", + "45", + "41", + "37", + "33", + "38", + "39", + "40", + "35", + "8" + ], + "defaults": [] + } +] \ No newline at end of file diff --git a/sources/es.manhwaweb/res/icon.png b/sources/es.manhwaweb/res/icon.png new file mode 100644 index 000000000..f528898e9 Binary files /dev/null and b/sources/es.manhwaweb/res/icon.png differ diff --git a/sources/es.manhwaweb/res/source.json b/sources/es.manhwaweb/res/source.json new file mode 100644 index 000000000..3ab6e49b6 --- /dev/null +++ b/sources/es.manhwaweb/res/source.json @@ -0,0 +1,26 @@ +{ + "info": { + "id": "es.manhwaweb", + "name": "ManhwaWeb", + "version": 1, + "url": "https://manhwaweb.com", + "contentRating": 2, + "languages": [ + "es" + ] + }, + "listings": [ + { + "id": "Latest", + "name": "Ultimas Actualizaciones" + }, + { + "id": "New", + "name": "Recien Agregados" + }, + { + "id": "Erotic", + "name": "+18 (Erotic)" + } + ] +} \ No newline at end of file diff --git a/sources/es.manhwaweb/src/helpers.rs b/sources/es.manhwaweb/src/helpers.rs new file mode 100644 index 000000000..684278301 --- /dev/null +++ b/sources/es.manhwaweb/src/helpers.rs @@ -0,0 +1,55 @@ +use aidoku::{ + Result, + alloc::String, + imports::net::{Request, TimeUnit, set_rate_limit}, +}; + +/// Initializes the rate limiter for this source. +/// Call this once during source initialization to enforce rate limits globally. +pub fn setup_rate_limit() { + // Allow 1 request every 2 seconds to avoid IP bans. + set_rate_limit(1, 2, TimeUnit::Seconds); +} + +/// Creates a request object for the given URL and method. +pub fn create_request(url: &str, method: &str) -> Result { + match method { + "POST" => Request::post(url).map_err(Into::into), + _ => Request::get(url).map_err(Into::into), + } +} + +/// Helper function to map genre IDs to Spanish names. +pub fn get_genre_name(id: &str) -> String { + match id { + "3" => "Accion", + "29" => "Aventura", + "18" => "Comedia", + "1" => "Drama", + "42" => "Recuentos de la vida", + "2" => "Romance", + "5" => "Venganza", + "6" => "Harem", + "23" => "Fantasia", + "31" => "Sobrenatural", + "25" => "Tragedia", + "43" => "Psicologico", + "32" => "Horror", + "44" => "Thriller", + "28" => "Historias cortas", + "30" => "Ecchi", + "34" => "Gore", + "37" => "Girls love", + "27" => "Boys love", + "45" => "Reencarnacion", + "41" => "Sistema de niveles", + "33" => "Ciencia ficcion", + "38" => "Apocaliptico", + "39" => "Artes marciales", + "40" => "Superpoderes", + "35" => "Cultivacion (cultivo)", + "8" => "Milf", + _ => "Desconocido", + } + .into() +} diff --git a/sources/es.manhwaweb/src/imp.rs b/sources/es.manhwaweb/src/imp.rs new file mode 100644 index 000000000..353f05409 --- /dev/null +++ b/sources/es.manhwaweb/src/imp.rs @@ -0,0 +1,339 @@ +use crate::models::*; +use aidoku::{ + Chapter, DeepLinkHandler, DeepLinkResult, FilterValue, Home, HomeComponent, HomeComponentValue, + HomeLayout, Listing, ListingKind, ListingProvider, Manga, MangaPageResult, Page, PageContent, + Result, Source, + alloc::{String, Vec, string::ToString, vec}, + helpers::uri::QueryParameters, + prelude::*, +}; + +const PER_PAGE: i32 = 18; +const BASE_URL: &str = "https://manhwaweb.com"; +const BACKEND_URL: &str = "https://manhwawebbackend-production.up.railway.app"; + +pub struct ManhwaWeb; + +impl Source for ManhwaWeb { + fn new() -> Self { + crate::helpers::setup_rate_limit(); + Self + } + + fn get_search_manga_list( + &self, + query: Option, + page: i32, + filters: Vec, + ) -> Result { + // API is 0-based, Aidoku is 1-based. + let api_page = page - 1; + let mut url = format!( + "{BACKEND_URL}/manhwa/library?page={}&perPage={}", + api_page, PER_PAGE + ); + let mut qs = QueryParameters::new(); + + // Handle search query + if let Some(q) = query { + let trimmed = q.trim(); + if !trimmed.is_empty() { + qs.push("buscar", Some(trimmed)); + } + } + + let mut erotic_filter_set = false; + let mut genre_values: Vec = Vec::new(); + + for filter in filters { + match filter { + FilterValue::Select { id, value } => { + if !value.is_empty() { + // Pass ID directly as API expects (e.g., 'tipo', 'demografia') + qs.push(&id, Some(&value)); + + if id == "erotico" { + erotic_filter_set = true; + } + } + } + FilterValue::MultiSelect { id, included, .. } => { + // For genres + if id == "genres" || id == "genreIds" || id == "generes" { + for val in included { + genre_values.push(val); + } + } + } + FilterValue::Sort { index, .. } => { + let sort_val = match index { + 0 => "alfabetico", + 2 => "num_chapter", + _ => "creacion", // Default to creation date + }; + qs.push("order_item", Some(sort_val)); + } + _ => {} + } + } + + // Handle Genres: joined by 'a' (e.g., "1a2a3") + if !genre_values.is_empty() { + let joined_genres = genre_values.join("a"); + qs.push("generes", Some(&joined_genres)); + } + + // Default to "no" erotic content if the filter wasn't explicitly set + if !erotic_filter_set { + qs.push("erotico", Some("no")); + } + + // Ensure qs is appended with '&' prefix because base URL might have query params already. + if !qs.is_empty() { + url.push_str(&format!("&{}", qs)); + } + + let data = crate::helpers::create_request(&url, "GET")? + .header("Referer", &format!("{}/", BASE_URL)) + .json_owned::()?; + let entries = data + .data + .into_iter() + .map(|m| m.to_manga(BASE_URL)) + .collect(); + // Pagination check: API returns 'next': boolean. + let has_next_page = data.next; + + Ok(MangaPageResult { + entries, + has_next_page, + }) + } + + fn get_manga_update( + &self, + mut manga: Manga, + needs_details: bool, + needs_chapters: bool, + ) -> Result { + let url = format!("{BACKEND_URL}/manhwa/see/{}", manga.key); + let data = crate::helpers::create_request(&url, "GET")? + .header("Referer", &format!("{}/", BASE_URL)) + .json_owned::()?; + + if needs_details { + manga.copy_from(data.parse_manga(BASE_URL)); + } + + if needs_chapters { + manga.chapters = Some(data.parse_chapters(BASE_URL)); + } + + Ok(manga) + } + + fn get_page_list(&self, _manga: Manga, chapter: Chapter) -> Result> { + let url = format!("{BACKEND_URL}/chapters/see/{}", chapter.key); + let data = + crate::helpers::create_request(&url, "GET")?.json_owned::()?; + + Ok(data + .chapter + .img + .into_iter() + .enumerate() + .map(|(_i, url)| Page { + content: PageContent::Url(url, None), + ..Default::default() + }) + .collect()) + } +} + +impl DeepLinkHandler for ManhwaWeb { + fn handle_deep_link(&self, url: String) -> Result> { + if let Some(path) = url.strip_prefix(BASE_URL) { + if path.starts_with("/manhwa/") { + let id = path.trim_start_matches("/manhwa/"); + return Ok(Some(DeepLinkResult::Manga { key: id.into() })); + } + } + Ok(None) + } +} + +impl Home for ManhwaWeb { + fn get_home(&self) -> Result { + let mut components = Vec::new(); + + // 1. Hero: Latest Chapters ("Nuevos Capitulos") - BigScroller + let url_latest = format!("{BACKEND_URL}/manhwa/nuevos"); + if let Ok(data) = crate::helpers::create_request(&url_latest, "GET") + .and_then(|r| r.json_owned::()) + { + let latest_entries: Vec = data + .manhwas + .spanish_manhwas + .into_iter() + .map(|m| { + let group = m.gru_name.unwrap_or_default(); + let subtitle = format!("Cap. {} - {}", m.chapter, group); + let url = Some(format!("{}/manhwa/{}", BASE_URL, m.id_manhwa)); + + Manga { + key: m.id_manhwa.into(), + title: m.name_manhwa, + authors: Some(vec![subtitle]), + cover: m.img.map(|s| s.into()), + url, + ..Default::default() + } + }) + .collect(); + + components.push(HomeComponent { + title: Some("Nuevos Capitulos".into()), + subtitle: None, + value: HomeComponentValue::BigScroller { + entries: latest_entries, + auto_scroll_interval: Some(5.0), + }, + }); + } + + // 2. New Works ("Nuevas Obras") - Scroller + let url_new = format!( + "{BACKEND_URL}/manhwa/library?page=0&perPage=12&order_item=creacion&order_dir=desc" + ); + if let Ok(data) = crate::helpers::create_request(&url_new, "GET") + .and_then(|r| r.json_owned::()) + { + let entries: Vec = data + .data + .into_iter() + .filter(|m| m.erotic.as_deref() != Some("si")) + .map(|m| { + let categories = m.categories.clone(); + let mut manga = m.to_manga(BASE_URL); + if let Some(cats) = categories { + let tags: Vec = cats + .iter() + .map(|id| crate::helpers::get_genre_name(&id.to_string())) + .collect(); + manga.tags = Some(tags); + } + manga.into() + }) + .collect(); + + components.push(HomeComponent { + title: Some("Nuevas Obras".into()), + subtitle: None, + value: HomeComponentValue::Scroller { + entries, + listing: Some(Listing { + id: "New".into(), + name: "Nuevas Obras".into(), + kind: ListingKind::Default, + }), + }, + }); + } + + Ok(HomeLayout { components }) + } +} + +impl ListingProvider for ManhwaWeb { + fn get_manga_list(&self, listing: Listing, page: i32) -> Result { + let name = listing.id.as_str(); + let api_page = page - 1; + + if name.starts_with("Genre:") { + let id = name.split(':').nth(1).unwrap_or(""); + // Ensure 'creacion' (creation date) sort is properly applied for these listings too + let url = format!( + "{BACKEND_URL}/manhwa/library?page={}&perPage={}&erotico=no&generes={}&order_item=creacion&order_dir=desc", + api_page, PER_PAGE, id + ); + let resp = + crate::helpers::create_request(&url, "GET")?.json_owned::()?; + let entries: Vec = resp + .data + .into_iter() + .map(|m| m.to_manga(BASE_URL)) + .collect(); + return Ok(MangaPageResult { + entries, + has_next_page: resp.next, + }); + } + + match name { + // "Latest" and "Popular" standard tabs fallback + // Note: The API does not strictly support a "Popular" endpoint that differs significantly from "Nuevos" in this context without specific implementation. + "Latest" | "Popular" => { + if page > 1 { + return Ok(MangaPageResult { + entries: vec![], + has_next_page: false, + }); + } + let url = format!("{BACKEND_URL}/manhwa/nuevos"); + let resp = + crate::helpers::create_request(&url, "GET")?.json_owned::()?; + let entries: Vec = resp + .manhwas + .spanish_manhwas + .into_iter() + .map(|m| { + let url = Some(format!("{}/manhwa/{}", BASE_URL, m.id_manhwa)); + Manga { + key: m.id_manhwa.into(), + title: m.name_manhwa, + cover: m.img.map(|s| s.into()), + url, + ..Default::default() + } + }) + .collect(); + Ok(MangaPageResult { + entries, + has_next_page: false, + }) + } + "New" => self.get_search_manga_list( + None, + page, + vec![FilterValue::Sort { + index: 3, + ascending: false, + id: "sortBy".into(), + }], + ), + "Erotic" | "+18 (Erotic)" => { + let url = format!( + "{BACKEND_URL}/manhwa/library?page={}&perPage={}&erotico=si", + api_page, PER_PAGE + ); + let data = crate::helpers::create_request(&url, "GET")? + .header("Referer", &format!("{}/", BASE_URL)) + .json_owned::()?; + let entries = data + .data + .into_iter() + .map(|m| m.to_manga(BASE_URL)) + .collect(); + Ok(MangaPageResult { + entries, + has_next_page: data.next, + }) + } + // Search fallback + _ => { + let filters = vec![]; + self.get_search_manga_list(None, page, filters) + } + } + } +} diff --git a/sources/es.manhwaweb/src/lib.rs b/sources/es.manhwaweb/src/lib.rs new file mode 100644 index 000000000..385ccc72d --- /dev/null +++ b/sources/es.manhwaweb/src/lib.rs @@ -0,0 +1,11 @@ +#![no_std] + +use aidoku::{Source, prelude::*}; + +mod helpers; +mod imp; +mod models; + +use imp::ManhwaWeb; + +register_source!(ManhwaWeb, Home, DeepLinkHandler, ListingProvider); diff --git a/sources/es.manhwaweb/src/models.rs b/sources/es.manhwaweb/src/models.rs new file mode 100644 index 000000000..07a764f15 --- /dev/null +++ b/sources/es.manhwaweb/src/models.rs @@ -0,0 +1,189 @@ +use aidoku::{ + Chapter, Manga, MangaStatus, Viewer, + alloc::{String, Vec}, + prelude::*, +}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct NuevosResponse { + // 'utimos_mangas_creados' (latest_mangas) and 'top' are never used by logic, + // so we can omit them. If the JSON structure requires them to be present but ignored, + // we can keep them or use `serde::IgnoredAny` if we want to be strict, + // but typically just omitting them from the struct works if `deny_unknown_fields` isn't on. + + // However, the `ManhwaWeb` logic accesses `data.manhwas.spanish_manhwas`. + pub manhwas: ManhwasCollection, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ManhwasCollection { + #[serde(rename = "manhwas_esp")] + pub spanish_manhwas: Vec, +} + +// TopCollection and TopManga were seemingly unused. Removed. + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateManga { + pub name_manhwa: String, + pub img: Option, + pub id_manhwa: String, + pub chapter: f32, + // 'create' was unused. + pub gru_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LibraryResponse { + pub data: Vec, + pub next: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LibraryManga { + #[serde(rename = "_id")] + pub id: String, + pub the_real_name: String, + #[serde(rename = "_imagen")] + pub image: Option, + #[serde(rename = "_status")] + pub status: Option, + #[serde(rename = "_erotico")] + pub erotic: Option, + #[serde(rename = "_categoris")] + pub categories: Option>, +} + +impl LibraryManga { + pub fn to_manga(self, base_url: &str) -> Manga { + let url = Some(format!("{}/manhwa/{}", base_url, self.id)); + Manga { + key: self.id.into(), + title: self.the_real_name, + cover: self.image.map(|s| s.into()), + url, + status: self + .status + .as_ref() + .map(|s| match s.as_str() { + "publicandose" => MangaStatus::Ongoing, + "finalizado" => MangaStatus::Completed, + "pausado" => MangaStatus::Hiatus, + _ => MangaStatus::Unknown, + }) + .unwrap_or(MangaStatus::Unknown), + ..Default::default() + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SeeResponse { + #[serde(rename = "_id")] + pub id: String, + pub the_real_name: String, + #[serde(rename = "_sinopsis")] + pub synopsis: Option, + #[serde(rename = "_imagen")] + pub image: Option, + #[serde(rename = "_status")] + pub status: Option, + #[serde(rename = "_tipo")] + pub serie_type: Option, + // Removed unused 'demography' and 'erotic' + #[serde(rename = "_categoris")] + pub categories: Option>, + pub chapters: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RawSeeChapter { + pub chapter: f32, + pub create: i64, + pub link: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChapterSeeResponse { + pub chapter: ChapterImgData, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChapterImgData { + pub img: Vec, +} + +impl SeeResponse { + pub fn parse_manga(&self, base_url: &str) -> Manga { + Manga { + key: self.id.clone().into(), + title: self.the_real_name.clone(), + description: self.synopsis.as_ref().map(|s| s.trim().into()), + cover: self.image.as_ref().map(|s| s.into()), + url: Some(format!("{base_url}/manhwa/{}", self.id)), + status: self + .status + .as_ref() + .map(|s| match s.as_str() { + "publicandose" => MangaStatus::Ongoing, + "finalizado" => MangaStatus::Completed, + "pausado" => MangaStatus::Hiatus, + _ => MangaStatus::Unknown, + }) + .unwrap_or(MangaStatus::Unknown), + viewer: self + .serie_type + .as_ref() + .map(|s| match s.as_str() { + "manhwa" | "manhua" => Viewer::Webtoon, + _ => Viewer::RightToLeft, + }) + .unwrap_or(Viewer::Unknown), + tags: self + .categories + .as_ref() + .map(|cats: &Vec| { + cats.iter() + .filter_map(|cat: &serde_json::Value| { + cat.as_object() + .and_then(|obj: &serde_json::Map| { + obj.values().next() + }) + .and_then(|v: &serde_json::Value| v.as_str()) + .map(|s: &str| s.into()) + }) + .collect() + }), + ..Default::default() + } + } + + pub fn parse_chapters(&self, _base_url: &str) -> Vec { + let mut chapters: Vec = self + .chapters + .iter() + .map(|c| { + let url = Some(c.link.clone()); + Chapter { + // The URL is like /leer/slug-number + // But the API for images uses the whole slug-number as ID (KEY) + key: c.link.rsplit('/').next().unwrap_or(&self.id).into(), + chapter_number: Some(c.chapter), + date_uploaded: Some(c.create / 1000), // convert ms to s + url, + ..Default::default() + } + }) + .collect(); + + // Sort by chapter number descending + chapters.sort_by(|a, b| { + b.chapter_number + .partial_cmp(&a.chapter_number) + .unwrap_or(core::cmp::Ordering::Equal) + }); + + chapters + } +}