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") ); } }