diff --git a/.env b/.env index c8710cf..580de06 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -DATABASE_URL=hottub.db \ No newline at end of file +DATABASE_URL=hottub.db +RUST_LOG=info +FLARE_URL=http://192.168.0.103:8191/v1 diff --git a/src/db.rs b/src/db.rs index 596f2a6..dc790d8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -45,6 +45,36 @@ pub fn insert_video( .execute(conn) } +// Replace any existing rows for `new_id` with a single fresh row. The `videos` +// table is created without a UNIQUE/PRIMARY KEY constraint, so a plain insert +// would append duplicates and `get_video` (which reads the first match) would +// keep returning the stalest copy. Delete-then-insert in a transaction keeps a +// single, up-to-date entry per id so background refreshes actually take effect. +#[cfg(any( + not(hottub_single_provider), + hottub_provider = "hanime", + hottub_provider = "hentaihaven", + hottub_provider = "missav", + hottub_provider = "perverzija", +))] +pub fn upsert_video( + conn: &mut SqliteConnection, + new_id: &str, + new_url: &str, +) -> Result { + use crate::models::DBVideo; + use crate::schema::videos::dsl::*; + conn.transaction(|conn| { + diesel::delete(videos.filter(id.eq(new_id))).execute(conn)?; + diesel::insert_into(videos) + .values(DBVideo { + id: new_id.to_string(), + url: new_url.to_string(), + }) + .execute(conn) + }) +} + #[cfg(any( not(hottub_single_provider), hottub_provider = "hanime", diff --git a/src/providers/hentaihaven.rs b/src/providers/hentaihaven.rs index 204885d..7a30411 100644 --- a/src/providers/hentaihaven.rs +++ b/src/providers/hentaihaven.rs @@ -2,19 +2,55 @@ use crate::api::ClientVersion; use crate::providers::Provider; use crate::status::*; use crate::util::cache::VideoCache; -use crate::util::discord::{format_error_chain, send_discord_error_report}; use crate::util::requester::Requester; use crate::videos::{ServerOptions, VideoFormat, VideoItem}; use crate::{DbPool, db}; use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use error_chain::error_chain; use futures::stream::{self, StreamExt}; use htmlentity::entity::{ICodedDataTrait, decode}; -use std::sync::{Arc, RwLock}; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::vec; use titlecase::Titlecase; use wreq::Version; +use wreq_util::Emulation; + +// How long a cached listing/search entry is considered usable at all. +const HARD_TTL_SECS: u64 = 60 * 60 * 24; +// Past this age we still answer instantly from cache/DB but trigger a +// background refresh so the next request gets fresh data / renewed signed URLs. +const SOFT_TTL_SECS: u64 = 60 * 60; + +#[derive(Debug, Deserialize)] +struct PlayerSecureConfig { + en: String, + iv: String, + uri: String, +} + +#[derive(Debug, Deserialize)] +struct PlayerApiSource { + src: String, + #[serde(default)] + label: String, +} + +#[derive(Debug, Deserialize, Default)] +struct PlayerApiData { + #[serde(default)] + sources: Vec, +} + +#[derive(Debug, Deserialize)] +struct PlayerApiResponse { + status: bool, + #[serde(default)] + data: Option, +} pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { @@ -96,22 +132,30 @@ impl HentaihavenProvider { ) -> Result> { let _ = sort; let video_url = format!("{}/hentai/page/{}/", self.url, page); - let old_items = match cache.get(&video_url) { - Some((time, items)) => { - if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 * 24 { - return Ok(items.clone()); - } else { - items.clone() - } - } - None => { - vec![] - } - }; + // Fast path: a usable in-memory entry exists. Answer immediately; once it + // is older than the soft TTL, kick a background refresh so the next caller + // sees fresher data without anyone waiting on it now. + if let Some((time, items)) = cache.get(&video_url) { + let age = time.elapsed().unwrap_or_default().as_secs(); + if age < HARD_TTL_SECS && !items.is_empty() { + if age >= SOFT_TTL_SECS { + let requester = crate::providers::requester_or_default( + &options, + module_path!(), + "missing_requester", + ); + self.spawn_refresh(requester, pool, cache, video_url, None, false); + } + return Ok(items); + } + } + + // Fetch the listing page (a single cheap request) to learn which episode + // URLs belong on this page and in what order. let mut requester = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); - let text = match requester.get(&video_url, Some(Version::HTTP_2)).await { + let text = match Self::get_with_retry(&mut requester, &video_url, 3).await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( @@ -120,19 +164,40 @@ impl HentaihavenProvider { &format!("url={video_url}; error={e}"), ) .await; - return Ok(old_items); + return Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()); } }; - let video_items: Vec = self - .get_video_items_from_html(text.clone(), &mut requester, pool.clone()) - .await; - if !video_items.is_empty() { - cache.remove(&video_url); - cache.insert(video_url.clone(), video_items.clone()); - } else { - return Ok(old_items); + let urls = Self::parse_listing_urls(&text); + if urls.is_empty() { + return Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()); } - Ok(video_items) + + // Serve whatever we have already resolved (from the DB) right away, then + // refresh the entire listing in the background. + let db_items = Self::items_from_db(&urls, &pool); + if !db_items.is_empty() { + cache.insert(video_url.clone(), db_items.clone()); + self.spawn_refresh(requester, pool, cache, video_url, Some(urls), false); + return Ok(db_items); + } + + // Cold start: nothing cached for any item yet, resolve synchronously this + // one time so the first ever request is not empty. + let items = self.resolve_urls(urls, &requester, pool).await; + if !items.is_empty() { + cache.insert(video_url.clone(), items.clone()); + return Ok(items); + } + Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()) } async fn query( @@ -143,25 +208,29 @@ impl HentaihavenProvider { options: ServerOptions, pool: DbPool, ) -> Result> { - let video_url = format!("{}/?s={}", self.url, query.replace(" ", "+"),); - // Check our Video Cache. If the result is younger than 1 hour, we return it. - let old_items = match cache.get(&video_url) { - Some((time, items)) => { - if time.elapsed().unwrap_or_default().as_secs() < 60 * 60 * 24 { - return Ok(items.clone()); - } else { - let _ = cache.check().await; - return Ok(items.clone()); + if page > 1 { + return Ok(vec![]); + } + let video_url = format!("{}/?s={}", self.url, query.replace(" ", "+")); + + if let Some((time, items)) = cache.get(&video_url) { + let age = time.elapsed().unwrap_or_default().as_secs(); + if age < HARD_TTL_SECS && !items.is_empty() { + if age >= SOFT_TTL_SECS { + let requester = crate::providers::requester_or_default( + &options, + module_path!(), + "missing_requester", + ); + self.spawn_refresh(requester, pool, cache, video_url, None, true); } + return Ok(items); } - None => { - vec![] - } - }; + } let mut requester = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); - let text = match requester.get(&video_url, Some(Version::HTTP_2)).await { + let text = match Self::get_with_retry(&mut requester, &video_url, 3).await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( @@ -170,107 +239,78 @@ impl HentaihavenProvider { &format!("url={video_url}; error={e}"), ) .await; - return Ok(old_items); + return Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()); } }; - if page > 1 { - return Ok(vec![]); + let urls = Self::parse_search_urls(&text); + if urls.is_empty() { + return Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()); } - let video_items: Vec = self - .get_video_items_from_html_search(text.clone(), &mut requester, pool) - .await; - if !video_items.is_empty() { - cache.remove(&video_url); - cache.insert(video_url.clone(), video_items.clone()); - } else { - return Ok(old_items); + + let db_items = Self::items_from_db(&urls, &pool); + if !db_items.is_empty() { + cache.insert(video_url.clone(), db_items.clone()); + self.spawn_refresh(requester, pool, cache, video_url, Some(urls), true); + return Ok(db_items); } - Ok(video_items) + + let items = self.resolve_urls(urls, &requester, pool).await; + if !items.is_empty() { + cache.insert(video_url.clone(), items.clone()); + return Ok(items); + } + Ok(cache + .get(&video_url) + .map(|(_, items)| items) + .unwrap_or_default()) } - async fn get_video_items_from_html( - &self, - html: String, - requester: &mut Requester, - pool: DbPool, - ) -> Vec { + fn extract_segment_url(seg: &str) -> Option { + seg.split("a href=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .map(|s| s.to_string()) + } + + /// Extract the ordered list of episode page URLs from a listing page. + fn parse_listing_urls(html: &str) -> Vec { if html.is_empty() || html.contains("404 Not Found") { return vec![]; } - - let block = match html - .split("previouspostslink") - .next() - .and_then(|s| { - s.split("vraven_manga_list").nth(1).or_else(|| { - s.find(r#"
"#) - .map(|idx| &s[idx..]) - }) + let block = match html.split("previouspostslink").next().and_then(|s| { + s.split("vraven_manga_list").nth(1).or_else(|| { + s.find(r#"
"#) + .map(|idx| &s[idx..]) }) - { + }) { Some(b) => b, None => { - eprint!("Hentai Haven Provider: Failed to get block from html"); - let e = Error::from(ErrorKind::Parse("html".into())); - send_discord_error_report( - e.to_string(), - Some(format_error_chain(&e)), - Some("Hentai Haven Provider"), - Some(&format!("Failed to get block from html:\n```{html}\n```")), - file!(), - line!(), - module_path!(), - ) - .await; + crate::providers::report_provider_error_background( + "hentaihaven", + "parse_listing.block", + "Failed to get block from listing html", + ); return vec![]; } }; - - let segments: Vec = block.split("id=\"manga-item-").skip(1).map(|el| el.to_string()).collect(); - stream::iter(segments.into_iter().map(|el| { - let pool = pool.clone(); - let requester = requester.clone(); - let provider = self.clone(); - async move { provider.get_video_item(el, pool, requester).await } - })) - .buffer_unordered(4) - .filter_map(|result| async move { - match result { - Ok(item) => Some(item), - Err(e) => { - eprint!("Hentai Haven Provider: Failed to get video item:{}\n", e); - let msg = e.to_string(); - let chain = format_error_chain(&e); - tokio::spawn(async move { - let _ = send_discord_error_report( - msg, - Some(chain), - Some("Hentai Haven Provider"), - Some("Failed to get video item"), - file!(), - line!(), - module_path!(), - ) - .await; - }); - None - } - } - }) - .collect::>() - .await + block + .split("id=\"manga-item-") + .skip(1) + .filter_map(Self::extract_segment_url) + .collect() } - async fn get_video_items_from_html_search( - &self, - html: String, - requester: &mut Requester, - pool: DbPool, - ) -> Vec { + /// Extract the ordered list of result URLs from a search page. + fn parse_search_urls(html: &str) -> Vec { if html.is_empty() || html.contains("404 Not Found") { return vec![]; } - let block = match html .split(" b, None => { - eprint!("Hentai Haven Provider: Failed to get block from html"); - let e = Error::from(ErrorKind::Parse("html".into())); - send_discord_error_report( - e.to_string(), - Some(format_error_chain(&e)), - Some("Hentai Haven Provider"), - Some(&format!("Failed to get block from html:\n```{html}\n```")), - file!(), - line!(), - module_path!(), - ) - .await; + crate::providers::report_provider_error_background( + "hentaihaven", + "parse_search.block", + "Failed to get block from search html", + ); return vec![]; } }; + block + .split("c-tabs-item__content col-6 col-md-12") + .skip(1) + .filter_map(Self::extract_segment_url) + .collect() + } - let segments: Vec = block.split("c-tabs-item__content col-6 col-md-12").skip(1).map(|el| el.to_string()).collect(); - stream::iter(segments.into_iter().map(|el| { - let pool = pool.clone(); - let requester = requester.clone(); + /// Build a response from already-resolved items stored in the DB, preserving + /// the order of `urls`. Items not yet in the DB are simply skipped. + fn items_from_db(urls: &[String], pool: &DbPool) -> Vec { + let mut conn = match pool.get() { + Ok(conn) => conn, + Err(_) => return vec![], + }; + urls.iter() + .filter_map(|url| match db::get_video(&mut conn, url.clone()) { + Ok(Some(json)) => VideoItem::from(json).ok(), + _ => None, + }) + .collect() + } + + /// Resolve each episode page URL into a full `VideoItem`, persisting every + /// success to the DB. On failure we fall back to any stored copy so a + /// transient error does not drop the item from the page. + async fn resolve_urls( + &self, + urls: Vec, + requester: &Requester, + pool: DbPool, + ) -> Vec { + stream::iter(urls.into_iter().map(|url| { let provider = self.clone(); - async move { provider.get_video_item(el, pool, requester).await } - })) - .buffer_unordered(4) - .filter_map(|result| async move { - match result { - Ok(item) => Some(item), - Err(e) => { - eprint!("Hentai Haven Provider: Failed to get video item:{}\n", e); - let msg = e.to_string(); - let chain = format_error_chain(&e); - tokio::spawn(async move { - let _ = send_discord_error_report( - msg, - Some(chain), - Some("Hentai Haven Provider"), - Some("Failed to get video item"), - file!(), - line!(), - module_path!(), - ) - .await; - }); - None + let mut req = requester.clone(); + let pool = pool.clone(); + async move { + match provider.fetch_video_item(&url, &mut req).await { + Ok(item) => { + if let Ok(mut conn) = pool.get() { + let new_len = item.formats.as_ref().map_or(0, |f| f.len()); + let old_item = db::get_video(&mut conn, url.clone()) + .ok() + .flatten() + .and_then(|json| VideoItem::from(json).ok()); + let old_len = old_item + .as_ref() + .and_then(|o| o.formats.as_ref()) + .map_or(0, |f| f.len()); + if new_len >= old_len { + let _ = db::upsert_video( + &mut conn, + &url, + &serde_json::to_string(&item).unwrap_or_default(), + ); + Some(item) + } else { + // A partial refresh resolved fewer episodes than we + // already have (likely a transient outage) — keep the + // richer stored copy rather than degrading it. + old_item.or(Some(item)) + } + } else { + Some(item) + } + } + Err(e) => { + eprintln!("Hentai Haven Provider: Failed to resolve {url}: {e}"); + if let Ok(mut conn) = pool.get() { + if let Ok(Some(cached)) = db::get_video(&mut conn, url.clone()) { + if let Ok(item) = VideoItem::from(cached) { + return Some(item); + } + } + } + None + } } } - }) + })) + .buffered(2) + .filter_map(|item| async move { item }) .collect::>() .await } - async fn get_video_item( - &self, - seg: String, - pool: DbPool, - mut requester: Requester, - ) -> Result { - let video_url = seg - .split("a href=\"") - .nth(1) - .and_then(|s| s.split('"').next()) - .ok_or_else(|| ErrorKind::Parse("video url\n\n{seg}".into()))? - .to_string(); + /// Per-listing in-flight guard so we never run two background refreshes for + /// the same page concurrently. + fn refresh_in_flight() -> &'static Mutex> { + static SET: OnceLock>> = OnceLock::new(); + SET.get_or_init(|| Mutex::new(HashSet::new())) + } - match self.fetch_video_item(&video_url, &mut requester).await { - Ok(video_item) => { - if let Ok(mut conn) = pool.get() { - let _ = db::insert_video( - &mut conn, - &video_url, - &serde_json::to_string(&video_item).unwrap_or_default(), - ); - } - Ok(video_item) - } - Err(e) => { - if let Ok(mut conn) = pool.get() { - if let Ok(Some(cached)) = db::get_video(&mut conn, video_url.clone()) { - if let Ok(item) = VideoItem::from(cached) { - return Ok(item); - } + fn try_begin_refresh(key: &str) -> bool { + match Self::refresh_in_flight().lock() { + Ok(mut set) => set.insert(key.to_string()), + Err(_) => false, + } + } + + fn end_refresh(key: &str) { + if let Ok(mut set) = Self::refresh_in_flight().lock() { + set.remove(key); + } + } + + /// Spawn a non-blocking refresh of a listing/search page. `urls` may be + /// supplied when the caller already fetched the listing; otherwise the + /// refresh re-fetches it itself. + fn spawn_refresh( + &self, + requester: Requester, + pool: DbPool, + cache: VideoCache, + key: String, + urls: Option>, + search: bool, + ) { + if !Self::try_begin_refresh(&key) { + return; + } + let provider = self.clone(); + tokio::spawn(async move { + provider + .refresh(requester, pool, cache, key.clone(), urls, search) + .await; + Self::end_refresh(&key); + }); + } + + async fn refresh( + &self, + mut requester: Requester, + pool: DbPool, + cache: VideoCache, + key: String, + urls: Option>, + search: bool, + ) { + let urls = match urls { + Some(urls) => urls, + None => match Self::get_with_retry(&mut requester, &key, 3).await { + Ok(text) => { + if search { + Self::parse_search_urls(&text) + } else { + Self::parse_listing_urls(&text) } } - Err(e) - } + Err(e) => { + crate::providers::report_provider_error_background( + "hentaihaven", + "refresh.request", + &format!("url={key}; error={e}"), + ); + return; + } + }, + }; + if urls.is_empty() { + return; + } + let items = self.resolve_urls(urls, &requester, pool).await; + if !items.is_empty() { + cache.insert(key, items); } } @@ -371,8 +498,7 @@ impl HentaihavenProvider { video_url: &str, requester: &mut Requester, ) -> Result { - let html = requester - .get(video_url, Some(Version::HTTP_2)) + let html = Self::get_with_retry(requester, video_url, 3) .await .map_err(|e| Error::from(format!("Failed to fetch video page: {}", e)))?; @@ -443,7 +569,6 @@ impl HentaihavenProvider { .and_then(|s| s.split(" Total").nth(0)) .map(|s| s.trim().parse::().unwrap_or(0)) .unwrap_or(0); - let mut formats = vec![]; let episode_block = html .split("manga-chapters-holder") .nth(1) @@ -451,46 +576,43 @@ impl HentaihavenProvider { .split("vraven_read") .nth(0) .unwrap_or_default(); - for episode in episode_block.split("wp-manga-chapter").skip(1) { - let ep_thumbnail = episode - .split(" src=\"") - .nth(1) - .and_then(|s| s.split('"').next()) - .unwrap_or_default(); - let episode_title = episode - .split("
") - .nth(1) - .and_then(|s| s.split('<').next()) - .unwrap_or_default() - .trim() - .to_string(); - let episode_id = ep_thumbnail.split('/').nth(5).unwrap_or_default(); - let episode_url = format!( - "https://master-lengs.org/api/v3/hh/{}/master.m3u8", - episode_id - ); - let format = VideoFormat::new(episode_url, "1080p".to_string(), "m3u8".to_string()) - .format_id(episode_title.clone()) - .http_header("Connection".to_string(), "keep-alive".to_string()) - .http_header( - "User-Agent".to_string(), - "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" - .to_string(), - ) - .http_header( - "Accept".to_string(), - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(), - ) - .http_header("Accept-Language".to_string(), "en-US,en;q=0.5".to_string()) - .http_header( - "Accept-Encoding".to_string(), - "gzip, deflate, br".to_string(), - ) - .http_header("Sec-Fetch-Mode".to_string(), "navigate".to_string()) - .http_header("Origin".to_string(), self.url.clone()) - .format_note(episode_title.clone()); - formats.push(format); - } + let episodes: Vec<(String, String)> = episode_block + .split("wp-manga-chapter") + .skip(1) + .filter_map(|episode| { + let href = episode + .split("a href=\"") + .nth(1) + .and_then(|s| s.split('"').next())? + .to_string(); + let title = episode + .split("
") + .nth(1) + .and_then(|s| s.split('<').next()) + .unwrap_or_default() + .trim() + .to_string(); + Some((title, href)) + }) + .collect(); + + let formats: Vec = stream::iter(episodes.into_iter().map(|(title, href)| { + let requester = requester.clone(); + let provider = self.clone(); + async move { provider.resolve_episode_format(title, href, requester).await } + })) + .buffered(1) + .filter_map(|result| async move { + match result { + Ok(format) => Some(format), + Err(e) => { + eprintln!("Hentai Haven Provider: Failed to resolve episode format: {e}"); + None + } + } + }) + .collect::>() + .await; if formats.is_empty() { return Err(Error::from(format!("No formats found for video URL: {}", video_url))); } @@ -506,6 +628,199 @@ impl HentaihavenProvider { .aspect_ratio(0.715), ) } + + async fn resolve_episode_format( + &self, + title: String, + href: String, + mut requester: Requester, + ) -> Result { + let episode_html = Self::get_with_retry(&mut requester, &href, 4) + .await + .map_err(|e| Error::from(format!("Failed to fetch episode page {href}: {e}")))?; + + let player_url = episode_html + .split("iframe src=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .map(|s| s.replace("&", "&")) + .ok_or_else(|| ErrorKind::Parse(format!("player iframe url: {href}")))?; + + let player_html = Self::get_with_retry(&mut requester, &player_url, 4) + .await + .map_err(|e| Error::from(format!("Failed to fetch player page {player_url}: {e}")))?; + + let token = player_html + .split("x-secure-token\" content=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .ok_or_else(|| ErrorKind::Parse(format!("secure token: {href}")))?; + + let config = Self::decode_secure_token(token)?; + let api_base = if config.uri.starts_with("//") { + format!("https:{}", config.uri) + } else { + config.uri.clone() + }; + let api_url = format!("{api_base}api.php"); + + let body = Self::build_player_api_body(&config.en, &config.iv); + + let text = Self::post_ajax_with_retry( + &api_url, + &body, + vec![ + ("Content-Type", "application/x-www-form-urlencoded"), + ("Accept", "*/*"), + ("Accept-Language", "en-US,en;q=0.5"), + ("Referer", player_url.as_str()), + ("Origin", self.url.as_str()), + ("Sec-Fetch-Dest", "empty"), + ("Sec-Fetch-Mode", "cors"), + ("Sec-Fetch-Site", "same-origin"), + ("X-Requested-With", "XMLHttpRequest"), + ], + 4, + ) + .await + .map_err(|e| Error::from(format!("Failed to call player api {api_url}: {e}")))?; + + let api_response: PlayerApiResponse = serde_json::from_str(&text) + .map_err(|e| Error::from(format!("Failed to parse player api body {api_url}: {e}")))?; + if !api_response.status { + return Err(Error::from(format!("player api returned status=false for {href}"))); + } + let source = api_response + .data + .and_then(|d| d.sources.into_iter().next()) + .ok_or_else(|| ErrorKind::Parse(format!("no sources in player api response: {href}")))?; + + let quality = if source.label.trim().is_empty() { + "auto".to_string() + } else { + source.label.to_ascii_lowercase() + }; + + Ok( + VideoFormat::new(source.src, quality, "m3u8".to_string()) + .format_id(title.clone()) + .http_header( + "User-Agent".to_string(), + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" + .to_string(), + ) + .http_header("Referer".to_string(), self.url.clone()) + .http_header("Origin".to_string(), self.url.clone()) + .format_note(title), + ) + } + + async fn get_with_retry( + requester: &mut Requester, + url: &str, + attempts: u32, + ) -> std::result::Result { + let mut last_err = String::new(); + for attempt in 0..attempts { + if attempt > 0 { + let backoff_ms = 500u64 * (1u64 << (attempt - 1).min(3)); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + } + match requester.get(url, Some(Version::HTTP_2)).await { + Ok(text) => return Ok(text), + Err(e) => last_err = e.to_string(), + } + } + Err(last_err) + } + + fn ajax_client() -> &'static wreq::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + wreq::Client::builder() + .cert_verification(false) + .emulation(Emulation::Chrome137) + .build() + .expect("Failed to build hentaihaven AJAX client") + }) + } + + async fn post_ajax_with_retry( + url: &str, + body: &str, + headers: Vec<(&str, &str)>, + attempts: u32, + ) -> std::result::Result { + let mut last_err = String::new(); + for attempt in 0..attempts { + if attempt > 0 { + let backoff_ms = 500u64 * (1u64 << (attempt - 1).min(3)); + tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; + } + let mut request = Self::ajax_client() + .post(url) + .version(Version::HTTP_2) + .body(body.to_string()); + for (key, value) in headers.iter() { + request = request.header(*key, *value); + } + match request.send().await { + Ok(response) => { + let status = response.status(); + match response.text().await { + Ok(text) if status.is_success() => return Ok(text), + Ok(_) => last_err = format!("status {status}"), + Err(e) => last_err = e.to_string(), + } + } + Err(e) => last_err = e.to_string(), + } + } + Err(last_err) + } + + fn build_player_api_body(en: &str, iv: &str) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer + .append_pair("action", "zarat_get_data_player_ajax") + .append_pair("a", en) + .append_pair("b", iv); + serializer.finish() + } + + fn decode_secure_token(token: &str) -> Result { + let stripped = token.strip_prefix("sha512-").unwrap_or(token); + let mut data = Self::rot13(stripped); + data = Self::decode_base64_layer(&data)?; + data = Self::rot13(&data); + data = Self::decode_base64_layer(&data)?; + data = Self::rot13(&data); + data = Self::decode_base64_layer(&data)?; + serde_json::from_str(&data) + .map_err(|e| Error::from(format!("Failed to parse secure token json: {e}"))) + } + + fn decode_base64_layer(value: &str) -> Result { + let mut normalized = value.trim().to_string(); + while normalized.len() % 4 != 0 { + normalized.push('='); + } + let bytes = STANDARD + .decode(normalized) + .map_err(|e| Error::from(format!("base64 decode failed: {e}")))?; + String::from_utf8(bytes).map_err(|e| Error::from(format!("utf8 decode failed: {e}"))) + } + + fn rot13(input: &str) -> String { + input + .chars() + .map(|c| match c { + 'A'..='Z' => (((c as u8 - b'A' + 13) % 26) + b'A') as char, + 'a'..='z' => (((c as u8 - b'a' + 13) % 26) + b'a') as char, + other => other, + }) + .collect() + } } #[async_trait]