diff --git a/sources/en.asurascans/res/settings.json b/sources/en.asurascans/res/settings.json index af5057da..3b15d787 100644 --- a/sources/en.asurascans/res/settings.json +++ b/sources/en.asurascans/res/settings.json @@ -10,5 +10,19 @@ "default": true } ] + }, + { + "type": "group", + "items": [ + { + "type": "login", + "key": "login", + "notification": "login", + "method": "web", + "title": "LOGIN", + "url": "https://asurascans.com/login", + "refreshes": ["listings"] + } + ] } ] diff --git a/sources/en.asurascans/res/source.json b/sources/en.asurascans/res/source.json index 3eb9bf0a..5002da15 100644 --- a/sources/en.asurascans/res/source.json +++ b/sources/en.asurascans/res/source.json @@ -2,11 +2,18 @@ "info": { "id": "en.asurascans", "name": "Asura Scans", - "version": 13, + "version": 14, "url": "https://asurascans.com", "contentRating": 0, - "languages": ["en"] + "languages": ["en"], + "minAppVersion": "0.7.1" }, + "listings": [ + { + "id": "Ranking", + "kind": 1 + } + ], "config": { "breakingChangeVersion": 12 } diff --git a/sources/en.asurascans/src/auth.rs b/sources/en.asurascans/src/auth.rs new file mode 100644 index 00000000..45a41094 --- /dev/null +++ b/sources/en.asurascans/src/auth.rs @@ -0,0 +1,48 @@ +use crate::{API_URL, models::*}; +use aidoku::{ + HashMap, Result, + alloc::string::String, + imports::{ + defaults::{DefaultValue, defaults_get, defaults_set, defaults_set_data}, + net::Request, + }, + prelude::*, +}; + +const AUTH_KEY: &str = "auth"; + +pub fn is_logged_in() -> bool { + defaults_get::(AUTH_KEY).is_some() +} + +pub fn is_subscribed() -> bool { + defaults_get::(AUTH_KEY).is_some_and(|s| s.is_subscribed) +} + +pub fn handle_login(cookies: HashMap) -> Result { + let Some(refresh_token) = cookies.get("refresh_token") else { + return Ok(false); + }; + let Ok(status) = refresh(refresh_token) else { + bail!("Failed to authenticate"); + }; + defaults_set_data(AUTH_KEY, status); + Ok(true) +} + +pub fn logout() { + defaults_set(AUTH_KEY, DefaultValue::Null); +} + +pub fn get_access_token() -> Result { + let old_status = defaults_get::(AUTH_KEY).ok_or(error!("Not logged in"))?; + let status = refresh(&old_status.refresh_token)?; + Ok(status.access_token) +} + +fn refresh(refresh_token: &str) -> Result { + let res: RefreshResponse = Request::post(format!("{API_URL}/auth/refresh"))? + .body(format!("{{\"refresh_token\":\"{refresh_token}\"}}")) + .json_owned()?; + Ok(LoginStatus::from(res)) +} diff --git a/sources/en.asurascans/src/lib.rs b/sources/en.asurascans/src/lib.rs index 1e5c81b9..d7042d21 100644 --- a/sources/en.asurascans/src/lib.rs +++ b/sources/en.asurascans/src/lib.rs @@ -1,9 +1,10 @@ #![no_std] use aidoku::{ - AidokuError, Chapter, ContentRating, DeepLinkHandler, DeepLinkResult, FilterValue, Home, - HomeComponent, HomeComponentValue, HomeLayout, Link, Manga, MangaPageResult, MangaStatus, - MangaWithChapter, MigrationHandler, Page, PageContent, Result, Source, Viewer, - alloc::{String, Vec, string::ToString}, + AidokuError, Chapter, ContentRating, DeepLinkHandler, DeepLinkResult, DynamicListings, + FilterValue, HashMap, Home, HomeComponent, HomeComponentValue, HomeLayout, Link, Listing, + ListingProvider, Manga, MangaPageResult, MangaStatus, MangaWithChapter, MigrationHandler, + NotificationHandler, Page, PageContent, Result, Source, Viewer, WebLoginHandler, + alloc::{String, Vec, string::ToString, vec}, helpers::uri::QueryParameters, imports::{ defaults::defaults_get, @@ -13,9 +14,14 @@ use aidoku::{ prelude::*, }; +mod auth; mod helpers; +mod models; + +use models::*; const BASE_URL: &str = "https://asurascans.com"; +const API_URL: &str = "https://api.asurascans.com/api"; struct AsuraScans; @@ -186,6 +192,7 @@ impl Source for AsuraScans { .ok_or_else(|| error!("Missing chapters"))?; let skip_locked = !defaults_get::("showLocked").unwrap_or(true); + let is_subscribed = auth::is_subscribed(); manga.chapters = Some( chapters_arr @@ -193,7 +200,8 @@ impl Source for AsuraScans { .filter_map(|obj| { let obj = obj[1].as_object()?; - let locked = obj["is_locked"][1].as_bool().unwrap_or_default(); + let locked = + !is_subscribed && obj["is_locked"][1].as_bool().unwrap_or_default(); if skip_locked && locked { return None; } @@ -228,7 +236,15 @@ impl Source for AsuraScans { fn get_page_list(&self, manga: Manga, chapter: Chapter) -> Result> { let url = helpers::get_chapter_url(&chapter.key, &manga.key); - let html = Request::get(url)?.html()?; + let mut req = Request::get(url)?; + if auth::is_subscribed() { + // untested + if let Ok(token) = auth::get_access_token() { + req.set_header("Authorization", &format!("Bearer {token}")); + req.set_header("Cookie", &format!("access_token={token}")); + } + } + let html = req.html()?; let island_props = html .select_first( @@ -385,4 +401,91 @@ impl MigrationHandler for AsuraScans { } } -register_source!(AsuraScans, Home, DeepLinkHandler, MigrationHandler); +impl ListingProvider for AsuraScans { + fn get_manga_list(&self, listing: Listing, page: i32) -> Result { + match listing.id.as_str() { + "Ranking" => { + let html = Request::get(format!("{BASE_URL}/series-ranking"))?.html()?; + let entries = html + .select(".comics-ranking-list > a") + .map(|els| { + els.filter_map(|el| { + Some(Manga { + key: el + .attr("abs:href") + .and_then(|url| helpers::get_manga_key(&url))?, + title: el.select_first(".flex-1 > .text-sm")?.own_text()?, + cover: el.select_first("img").and_then(|el| el.attr("abs:src")), + ..Default::default() + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(MangaPageResult { + entries, + has_next_page: false, + }) + } + "Bookmarks" => { + let offset = 20 * (page - 1); + let token = auth::get_access_token()?; + let url = format!( + "{API_URL}/me/bookmarks?sort=updated&order=desc&limit=20&offset={offset}", + ); + let json: BookmarkResponse = Request::get(url)? + .header("Authorization", &format!("Bearer {token}")) + .json_owned()?; + let entries = json.data.into_iter().map(Into::into).collect(); + let has_next_page = page < json.meta.total; + Ok(MangaPageResult { + entries, + has_next_page, + }) + } + _ => bail!("Invalid listing"), + } + } +} + +impl DynamicListings for AsuraScans { + fn get_dynamic_listings(&self) -> Result> { + if !auth::is_logged_in() { + return Ok(Vec::new()); + } + Ok(vec![Listing { + id: "Bookmarks".into(), + name: "Bookmarks".into(), + ..Default::default() + }]) + } +} + +impl WebLoginHandler for AsuraScans { + fn handle_web_login(&self, _key: String, cookies: HashMap) -> Result { + auth::handle_login(cookies) + } +} + +impl NotificationHandler for AsuraScans { + fn handle_notification(&self, notification: String) { + if notification != "login" { + return; + } + let is_logged_in = defaults_get::("login").is_some(); + if !is_logged_in { + auth::logout(); + } + } +} + +register_source!( + AsuraScans, + Home, + DeepLinkHandler, + MigrationHandler, + ListingProvider, + DynamicListings, + WebLoginHandler, + NotificationHandler +); diff --git a/sources/en.asurascans/src/models.rs b/sources/en.asurascans/src/models.rs new file mode 100644 index 00000000..ef05639b --- /dev/null +++ b/sources/en.asurascans/src/models.rs @@ -0,0 +1,74 @@ +use aidoku::{ + Manga, + alloc::{string::String, vec::Vec}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct LoginStatus { + pub access_token: String, + pub refresh_token: String, + pub is_subscribed: bool, +} + +impl From for LoginStatus { + fn from(value: RefreshResponse) -> Self { + Self { + access_token: value.data.access_token, + refresh_token: value.data.refresh_token, + is_subscribed: value.data.subscription_status.has_subscription, + } + } +} + +#[derive(Deserialize)] +pub struct RefreshResponse { + pub data: RefreshResponseData, +} + +#[derive(Deserialize)] +pub struct RefreshResponseData { + pub access_token: String, + pub refresh_token: String, + pub subscription_status: RefreshSubscriptionStatus, +} + +#[derive(Deserialize)] +pub struct RefreshSubscriptionStatus { + pub has_subscription: bool, +} + +#[derive(Deserialize)] +pub struct BookmarkResponse { + pub data: Vec, + pub meta: BookmarkResponseMeta, +} + +#[derive(Deserialize)] +pub struct BookmarkResponseMeta { + pub total: i32, +} + +#[derive(Deserialize)] +pub struct BookmarkItem { + // pub id: i32, + series: BookmarkSeries, +} + +impl From for Manga { + fn from(value: BookmarkItem) -> Self { + Manga { + key: value.series.slug, + title: value.series.title, + cover: Some(value.series.cover_url), + ..Default::default() + } + } +} + +#[derive(Deserialize)] +pub struct BookmarkSeries { + cover_url: String, + slug: String, + title: String, +}