diff --git a/src/providers/pornhd3x.rs b/src/providers/pornhd3x.rs index 53666df..335191e 100644 --- a/src/providers/pornhd3x.rs +++ b/src/providers/pornhd3x.rs @@ -1,7 +1,8 @@ use crate::DbPool; use crate::api::ClientVersion; use crate::providers::{ - Provider, report_provider_error, report_provider_error_background, requester_or_default, + Provider, build_proxy_url, report_provider_error, report_provider_error_background, + requester_or_default, strip_url_scheme, }; use crate::status::*; use crate::util::cache::VideoCache; @@ -395,6 +396,39 @@ impl Pornhd3xProvider { .map(|value| value.id.clone()) } + fn is_allowed_detail_url(&self, value: &str) -> bool { + let normalized = self.normalize_url(value); + let Some(url) = Url::parse(&normalized).ok() else { + return false; + }; + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + (host == "www.pornhd3x.tv" || host == "pornhd3x.tv") && url.path().starts_with("/movies/") + } + + fn proxied_video( + &self, + options: &ServerOptions, + detail_url: &str, + quality: Option<&str>, + ) -> String { + if detail_url.is_empty() || !self.is_allowed_detail_url(detail_url) { + return detail_url.to_string(); + } + + let mut target = strip_url_scheme(detail_url); + if let Some(quality) = quality.map(str::trim).filter(|quality| !quality.is_empty()) { + target.push_str("/__quality__/"); + target.push_str(&quality.replace(' ', "%20")); + } + + build_proxy_url(options, CHANNEL_ID, &target) + } + fn filters_need_refresh(&self) -> bool { let categories_len = self .categories @@ -967,7 +1001,12 @@ impl Pornhd3xProvider { Ok(serde_json::from_str::(&response)?) } - fn build_formats(&self, value: &Value) -> Vec { + fn build_formats( + &self, + value: &Value, + options: &ServerOptions, + detail_url: &str, + ) -> Vec { let mut formats = Vec::new(); for playlist in value .get("playlist") @@ -994,7 +1033,8 @@ impl Pornhd3xProvider { .unwrap_or("HLS") .to_string(); let format_name = if url.contains(".m3u8") { "hls" } else { "mp4" }; - let format = VideoFormat::new(url, quality.clone(), format_name.to_string()) + let format_url = self.proxied_video(options, detail_url, Some(&quality)); + let format = VideoFormat::new(format_url, quality.clone(), format_name.to_string()) .format_id(quality.to_ascii_lowercase()) .format_note(quality); formats.push(format); @@ -1035,17 +1075,22 @@ impl Pornhd3xProvider { } }; - let mut formats = self.build_formats(&source_payload); - let direct_url = formats - .first() - .map(|format| format.url.clone()) - .or_else(|| { - source_payload - .get("embed_url") - .or_else(|| source_payload.get("embedUrl")) - .and_then(|value| value.as_str()) - .map(|value| self.normalize_url(value)) - }); + let mut formats = self.build_formats(&source_payload, options, &stub.detail_url); + let proxied_url = self.proxied_video(options, &stub.detail_url, None); + let direct_url = if !proxied_url.is_empty() { + Some(proxied_url) + } else { + formats + .first() + .map(|format| format.url.clone()) + .or_else(|| { + source_payload + .get("embed_url") + .or_else(|| source_payload.get("embedUrl")) + .and_then(|value| value.as_str()) + .map(|value| self.normalize_url(value)) + }) + }; let Some(url) = direct_url else { return Ok(None); @@ -1295,4 +1340,41 @@ mod tests { .is_some() ); } + + #[test] + fn builds_proxied_video_urls() { + let provider = Pornhd3xProvider::new_for_tests(); + let options = ServerOptions { + featured: None, + category: None, + sites: None, + filter: None, + language: None, + public_url_base: Some("https://example.com".to_string()), + requester: None, + network: None, + stars: None, + categories: None, + duration: None, + sort: None, + sexuality: None, + }; + + assert_eq!( + provider.proxied_video( + &options, + "https://www.pornhd3x.tv/movies/example-video", + None, + ), + "https://example.com/proxy/pornhd3x/www.pornhd3x.tv/movies/example-video" + ); + assert_eq!( + provider.proxied_video( + &options, + "https://www.pornhd3x.tv/movies/example-video", + Some("720p"), + ), + "https://example.com/proxy/pornhd3x/www.pornhd3x.tv/movies/example-video/__quality__/720p" + ); + } } diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 8e1e4ef..009abeb 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -1,4 +1,5 @@ use crate::proxies::doodstream::DoodstreamProxy; +use crate::proxies::pornhd3x::Pornhd3xProxy; use ntex::web; use crate::proxies::pimpbunny::PimpbunnyProxy; @@ -15,6 +16,7 @@ pub mod pimpbunny; pub mod pimpbunnythumb; pub mod porndish; pub mod porndishthumb; +pub mod pornhd3x; pub mod spankbang; pub mod sxyprn; @@ -23,6 +25,7 @@ pub enum AnyProxy { Doodstream(DoodstreamProxy), Sxyprn(SxyprnProxy), Javtiful(javtiful::JavtifulProxy), + Pornhd3x(Pornhd3xProxy), Pimpbunny(PimpbunnyProxy), Porndish(PorndishProxy), Spankbang(SpankbangProxy), @@ -38,6 +41,7 @@ impl Proxy for AnyProxy { AnyProxy::Doodstream(p) => p.get_video_url(url, requester).await, AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await, AnyProxy::Javtiful(p) => p.get_video_url(url, requester).await, + AnyProxy::Pornhd3x(p) => p.get_video_url(url, requester).await, AnyProxy::Pimpbunny(p) => p.get_video_url(url, requester).await, AnyProxy::Porndish(p) => p.get_video_url(url, requester).await, AnyProxy::Spankbang(p) => p.get_video_url(url, requester).await, diff --git a/src/proxies/pornhd3x.rs b/src/proxies/pornhd3x.rs new file mode 100644 index 0000000..40dd904 --- /dev/null +++ b/src/proxies/pornhd3x.rs @@ -0,0 +1,243 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use ntex::web; +use regex::Regex; +use serde_json::Value; +use url::Url; +use wreq::Version; + +use crate::util::requester::Requester; + +const BASE_URL: &str = "https://www.pornhd3x.tv"; +const SOURCE_SECRET: &str = "98126avrbi6m49vd7shxkn985"; +const SOURCE_COOKIE_PREFIX: &str = "826avrbi6m49vd7shxkn985m"; +const SOURCE_COOKIE_SUFFIX: &str = "k06twz87wwxtp3dqiicks2df"; + +#[derive(Debug, Clone)] +pub struct Pornhd3xProxy { + source_counter: Arc, +} + +impl Pornhd3xProxy { + pub fn new() -> Self { + Self { + source_counter: Arc::new(AtomicU32::new(0)), + } + } + + fn normalize_detail_request(endpoint: &str) -> Option<(String, Option)> { + let endpoint = endpoint.trim().trim_start_matches('/'); + if endpoint.is_empty() { + return None; + } + + let (detail_part, quality) = match endpoint.split_once("/__quality__/") { + Some((detail, quality)) => { + (detail, Some(quality.replace("%20", " ").trim().to_string())) + } + None => (endpoint, None), + }; + + let detail_url = + if detail_part.starts_with("http://") || detail_part.starts_with("https://") { + detail_part.to_string() + } else { + format!("https://{}", detail_part.trim_start_matches('/')) + }; + + Self::is_allowed_detail_url(&detail_url) + .then_some((detail_url, quality.filter(|value| !value.is_empty()))) + } + + fn is_allowed_detail_url(url: &str) -> bool { + let Some(url) = Url::parse(url).ok() else { + return false; + }; + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + (host == "www.pornhd3x.tv" || host == "pornhd3x.tv") && url.path().starts_with("/movies/") + } + + fn normalize_url(raw: &str) -> String { + let value = raw.trim(); + if value.is_empty() { + return String::new(); + } + if value.starts_with("//") { + return format!("https:{value}"); + } + if value.starts_with('/') { + return format!("{BASE_URL}{value}"); + } + if value.starts_with("http://") { + return value.replacen("http://", "https://", 1); + } + value.to_string() + } + + fn extract_episode_id(html: &str) -> Option { + Regex::new(r#"(?is)(?:id=["']uuid["'][^>]*value=["']|episode-id=["'])([A-Za-z0-9]+)"#) + .ok()? + .captures(html) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str().to_string()) + } + + fn build_source_cookie_name(episode_id: &str) -> String { + format!("{SOURCE_COOKIE_PREFIX}{episode_id}{SOURCE_COOKIE_SUFFIX}") + } + + fn build_source_hash(episode_id: &str, nonce: &str) -> String { + format!( + "{:x}", + md5::compute(format!("{episode_id}{nonce}{SOURCE_SECRET}")) + ) + } + + fn next_source_request(&self) -> (u32, String) { + let count = self.source_counter.fetch_add(1, Ordering::Relaxed) + 1; + let nonce = format!("{:06x}", count % 0xFF_FFFF); + (count, nonce) + } + + async fn fetch_sources( + &self, + requester: &mut Requester, + referer: &str, + episode_id: &str, + ) -> Option { + let (count, nonce) = self.next_source_request(); + let source_url = format!( + "{BASE_URL}/ajax/get_sources/{episode_id}/{hash}?count={count}&mobile=true", + hash = Self::build_source_hash(episode_id, &nonce), + ); + let existing_cookie = requester.cookie_header_for_url(&source_url); + let cookie_value = format!("{}={nonce}", Self::build_source_cookie_name(episode_id)); + let combined_cookie = match existing_cookie { + Some(existing) if !existing.trim().is_empty() => format!("{existing}; {cookie_value}"), + _ => cookie_value, + }; + + let response = requester + .get_with_headers( + &source_url, + vec![ + ("Cookie".to_string(), combined_cookie), + ("Referer".to_string(), referer.to_string()), + ("X-Requested-With".to_string(), "XMLHttpRequest".to_string()), + ( + "Accept".to_string(), + "application/json, text/javascript, */*; q=0.01".to_string(), + ), + ], + Some(Version::HTTP_11), + ) + .await + .ok()?; + + serde_json::from_str::(&response).ok() + } + + fn select_source_url(payload: &Value, quality: Option<&str>) -> Option { + let sources = payload + .get("playlist") + .and_then(Value::as_array) + .into_iter() + .flatten() + .flat_map(|playlist| { + playlist + .get("sources") + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .collect::>(); + + if let Some(quality) = quality { + let quality = quality.trim().to_ascii_lowercase(); + for source in &sources { + let label = source + .get("label") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if label == quality { + let file = source.get("file").and_then(Value::as_str)?; + return Some(Self::normalize_url(file)); + } + } + } + + for source in sources { + let Some(file) = source.get("file").and_then(Value::as_str) else { + continue; + }; + let url = Self::normalize_url(file); + if !url.is_empty() { + return Some(url); + } + } + + None + } +} + +impl crate::proxies::Proxy for Pornhd3xProxy { + async fn get_video_url(&self, url: String, requester: web::types::State) -> String { + let Some((detail_url, quality)) = Self::normalize_detail_request(&url) else { + return String::new(); + }; + + let mut requester = requester.get_ref().clone(); + let detail_html = match requester.get(&detail_url, Some(Version::HTTP_11)).await { + Ok(text) => text, + Err(_) => return String::new(), + }; + let Some(episode_id) = Self::extract_episode_id(&detail_html) else { + return String::new(); + }; + let Some(payload) = self + .fetch_sources(&mut requester, &detail_url, &episode_id) + .await + else { + return String::new(); + }; + + Self::select_source_url(&payload, quality.as_deref()).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::Pornhd3xProxy; + + #[test] + fn normalizes_detail_endpoint_and_quality() { + let (url, quality) = Pornhd3xProxy::normalize_detail_request( + "www.pornhd3x.tv/movies/example-video/__quality__/720p", + ) + .expect("proxy target should parse"); + + assert_eq!(url, "https://www.pornhd3x.tv/movies/example-video"); + assert_eq!(quality.as_deref(), Some("720p")); + } + + #[test] + fn extracts_episode_id_from_detail_markup() { + let html = r#" + + + "#; + + assert_eq!( + Pornhd3xProxy::extract_episode_id(html).as_deref(), + Some("49Q27JL3HCPVNJQN") + ); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index c072ee9..fe40cfc 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -4,6 +4,7 @@ use crate::proxies::doodstream::DoodstreamProxy; use crate::proxies::javtiful::JavtifulProxy; use crate::proxies::pimpbunny::PimpbunnyProxy; use crate::proxies::porndish::PorndishProxy; +use crate::proxies::pornhd3x::Pornhd3xProxy; use crate::proxies::spankbang::SpankbangProxy; use crate::proxies::sxyprn::SxyprnProxy; use crate::proxies::*; @@ -35,6 +36,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) + .service( + web::resource("/pornhd3x/{endpoint}*") + .route(web::post().to(proxy2redirect)) + .route(web::get().to(proxy2redirect)), + ) .service( web::resource("/pimpbunny/{endpoint}*") .route(web::post().to(proxy2redirect)) @@ -92,6 +98,7 @@ fn get_proxy(proxy: &str) -> Option { "doodstream" => Some(AnyProxy::Doodstream(DoodstreamProxy::new())), "sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())), "javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())), + "pornhd3x" => Some(AnyProxy::Pornhd3x(Pornhd3xProxy::new())), "pimpbunny" => Some(AnyProxy::Pimpbunny(PimpbunnyProxy::new())), "porndish" => Some(AnyProxy::Porndish(PorndishProxy::new())), "spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())),