hentaihaven
This commit is contained in:
2
.env
2
.env
@@ -1 +1,3 @@
|
||||
DATABASE_URL=hottub.db
|
||||
RUST_LOG=info
|
||||
FLARE_URL=http://192.168.0.103:8191/v1
|
||||
|
||||
30
src/db.rs
30
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<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",
|
||||
|
||||
@@ -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 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);
|
||||
}
|
||||
Ok(video_items)
|
||||
let urls = Self::parse_search_urls(&text);
|
||||
if urls.is_empty() {
|
||||
return 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> {
|
||||
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);
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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| {
|
||||
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),
|
||||
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) => {
|
||||
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;
|
||||
});
|
||||
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();
|
||||
|
||||
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(),
|
||||
);
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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);
|
||||
crate::providers::report_provider_error_background(
|
||||
"hentaihaven",
|
||||
"refresh.request",
|
||||
&format!("url={key}; error={e}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
if urls.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
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=\"")
|
||||
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())
|
||||
.unwrap_or_default();
|
||||
let episode_title = episode
|
||||
.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();
|
||||
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);
|
||||
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("&", "&"))
|
||||
.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]
|
||||
|
||||
Reference in New Issue
Block a user