hentaihaven

This commit is contained in:
Simon
2026-06-18 11:24:09 +00:00
parent 751fc7765a
commit 25ea03d696
3 changed files with 582 additions and 235 deletions

2
.env
View File

@@ -1 +1,3 @@
DATABASE_URL=hottub.db
RUST_LOG=info
FLARE_URL=http://192.168.0.103:8191/v1

View File

@@ -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<usize, diesel::result::Error> {
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",

View File

@@ -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<PlayerApiSource>,
}
#[derive(Debug, Deserialize)]
struct PlayerApiResponse {
status: bool,
#[serde(default)]
data: Option<PlayerApiData>,
}
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
@@ -96,22 +132,30 @@ impl HentaihavenProvider {
) -> Result<Vec<VideoItem>> {
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<VideoItem> = 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<Vec<VideoItem>> {
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<VideoItem> = 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<VideoItem> {
fn extract_segment_url(seg: &str) -> Option<String> {
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<String> {
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#"<div class="page-content-listing item-big_thumbnail">"#)
.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#"<div class="page-content-listing item-big_thumbnail">"#)
.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<String> = 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::<Vec<_>>()
.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<VideoItem> {
/// Extract the ordered list of result URLs from a search page.
fn parse_search_urls(html: &str) -> Vec<String> {
if html.is_empty() || html.contains("404 Not Found") {
return vec![];
}
let block = match html
.split("<footer")
.next()
@@ -278,91 +318,178 @@ impl HentaihavenProvider {
{
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_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<String> = 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<VideoItem> {
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<String>,
requester: &Requester,
pool: DbPool,
) -> Vec<VideoItem> {
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::<Vec<_>>()
.await
}
async fn get_video_item(
&self,
seg: String,
pool: DbPool,
mut requester: Requester,
) -> Result<VideoItem> {
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<HashSet<String>> {
static SET: OnceLock<Mutex<HashSet<String>>> = 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<Vec<String>>,
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<Vec<String>>,
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<VideoItem> {
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::<u32>().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("<div>")
.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("<div>")
.nth(1)
.and_then(|s| s.split('<').next())
.unwrap_or_default()
.trim()
.to_string();
Some((title, href))
})
.collect();
let formats: Vec<VideoFormat> = 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::<Vec<_>>()
.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<VideoFormat> {
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("&amp;", "&"))
.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<String, String> {
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<wreq::Client> = 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<String, String> {
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<PlayerSecureConfig> {
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<String> {
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]