use ntex::web; use regex::{Captures, Regex}; use url::Url; use crate::util::requester::Requester; #[derive(Debug, Clone)] pub struct DoodstreamProxy {} impl DoodstreamProxy { pub fn new() -> Self { Self {} } fn normalize_detail_url(endpoint: &str) -> Option { let normalized = if endpoint.starts_with("http://") || endpoint.starts_with("https://") { endpoint.trim().to_string() } else { format!("https://{}", endpoint.trim_start_matches('/')) }; Self::is_allowed_detail_url(&normalized).then_some(normalized) } fn is_allowed_host(host: &str) -> bool { matches!( host, "turboplayers.xyz" | "www.turboplayers.xyz" | "trailerhg.xyz" | "www.trailerhg.xyz" | "streamhg.com" | "www.streamhg.com" ) } 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; }; if !Self::is_allowed_host(host) { return false; } url.path().starts_with("/t/") || url.path().starts_with("/e/") || url.path().starts_with("/d/") } fn request_origin(detail_url: &str) -> Option { let parsed = Url::parse(detail_url).ok()?; let host = parsed.host_str()?; Some(format!("{}://{}", parsed.scheme(), host)) } fn request_headers(detail_url: &str) -> Vec<(String, String)> { let origin = Self::request_origin(detail_url) .unwrap_or_else(|| "https://turboplayers.xyz".to_string()); vec![ ( "Referer".to_string(), format!("{}/", origin.trim_end_matches('/')), ), ("Origin".to_string(), origin), ( "Accept".to_string(), "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(), ), ("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()), ("Sec-Fetch-Site".to_string(), "same-origin".to_string()), ] } fn regex(pattern: &str) -> Option { Regex::new(pattern).ok() } fn decode_base36(token: &str) -> Option { usize::from_str_radix(token, 36).ok() } fn sanitize_media_url(url: &str) -> String { url.trim() .trim_end_matches('\\') .trim_end_matches('"') .trim_end_matches('\'') .to_string() } fn extract_literal_url(text: &str) -> Option { let direct_patterns = [ r#"urlPlay\s*=\s*'(?Phttps?://[^']+)'"#, r#"data-hash\s*=\s*"(?Phttps?://[^"]+)""#, r#""(?Phttps?://[^"]+\.(?:m3u8|mp4)(?:\?[^"]*)?)""#, r#"'(?Phttps?://[^']+\.(?:m3u8|mp4)(?:\?[^']*)?)'"#, ]; for pattern in direct_patterns { let Some(regex) = Self::regex(pattern) else { continue; }; if let Some(url) = regex .captures(text) .and_then(|captures| captures.name("url")) .map(|value| Self::sanitize_media_url(value.as_str())) { return Some(url); } } None } fn extract_packed_eval_args(text: &str) -> Option<(String, usize, usize, Vec)> { let regex = Self::regex( r#"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('(?P(?:\\'|\\\\|[^'])*)',(?P\d+),(?P\d+),'(?P(?:\\'|\\\\|[^'])*)'\.split\('\|'\)"#, )?; let captures = regex.captures(text)?; let payload = Self::decode_js_single_quoted(captures.name("payload")?.as_str()); let radix = captures.name("radix")?.as_str().parse::().ok()?; let count = captures.name("count")?.as_str().parse::().ok()?; let symbols = Self::decode_js_single_quoted(captures.name("symbols")?.as_str()); let parts = symbols.split('|').map(|value| value.to_string()).collect(); Some((payload, radix, count, parts)) } fn decode_js_single_quoted(value: &str) -> String { let mut result = String::with_capacity(value.len()); let mut chars = value.chars(); while let Some(ch) = chars.next() { if ch != '\\' { result.push(ch); continue; } match chars.next() { Some('\\') => result.push('\\'), Some('\'') => result.push('\''), Some('"') => result.push('"'), Some('n') => result.push('\n'), Some('r') => result.push('\r'), Some('t') => result.push('\t'), Some(other) => { result.push('\\'); result.push(other); } None => result.push('\\'), } } result } fn unpack_packer(text: &str) -> Option { let (mut payload, radix, count, symbols) = Self::extract_packed_eval_args(text)?; if radix != 36 { return None; } let token_regex = Self::regex(r"\b[0-9a-z]+\b")?; payload = token_regex .replace_all(&payload, |captures: &Captures| { let token = captures .get(0) .map(|value| value.as_str()) .unwrap_or_default(); let Some(index) = Self::decode_base36(token) else { return token.to_string(); }; if index >= count { return token.to_string(); } let replacement = symbols.get(index).map(|value| value.as_str()).unwrap_or(""); if replacement.is_empty() { token.to_string() } else { replacement.to_string() } }) .to_string(); Some(payload) } fn collect_media_candidates(text: &str) -> Vec { let Some(regex) = Self::regex(r#"https?://[^\s"'<>]+?\.(?:m3u8|mp4|txt)(?:\?[^\s"'<>]*)?"#) else { return vec![]; }; let mut urls = regex .find_iter(text) .map(|value| Self::sanitize_media_url(value.as_str())) .filter(|url| url.starts_with("https://")) .collect::>(); urls.sort_by_key(|url| { if url.contains(".m3u8") { 0 } else if url.contains(".mp4") { 1 } else { 2 } }); urls.dedup(); urls } fn extract_stream_url(text: &str) -> Option { if let Some(url) = Self::extract_literal_url(text) { return Some(url); } let unpacked = Self::unpack_packer(text)?; Self::collect_media_candidates(&unpacked) .into_iter() .next() .or_else(|| Self::extract_literal_url(&unpacked)) } fn extract_pass_md5_url(text: &str, detail_url: &str) -> Option { let decoded = text.replace("\\/", "/"); let absolute_regex = Self::regex(r#"https?://[^\s"'<>]+/pass_md5/[^\s"'<>]+"#)?; if let Some(url) = absolute_regex .find(&decoded) .map(|value| value.as_str().to_string()) { return Some(url); } let relative_regex = Self::regex(r#"/pass_md5/[^\s"'<>]+"#)?; let relative = relative_regex.find(&decoded)?.as_str(); let origin = Self::request_origin(detail_url)?; Some(format!("{origin}{relative}")) } fn compose_pass_md5_media_url(pass_md5_url: &str, response_body: &str) -> Option { let raw = response_body .trim() .trim_matches('"') .trim_matches('\'') .replace("\\/", "/"); if raw.is_empty() { return None; } let mut media_url = if raw.starts_with("https://") || raw.starts_with("http://") { raw } else if let Some(rest) = raw.strip_prefix("//") { format!("https://{rest}") } else { let parsed = Url::parse(pass_md5_url).ok()?; let host = parsed.host_str()?; format!("{}://{}{}", parsed.scheme(), host, raw) }; let query = Url::parse(pass_md5_url) .ok() .and_then(|url| url.query().map(str::to_string)); if let Some(query) = query { if !query.is_empty() && !media_url.contains("token=") { let separator = if media_url.contains('?') { '&' } else { '?' }; media_url.push(separator); media_url.push_str(&query); } } Some(Self::sanitize_media_url(&media_url)) } async fn resolve_stream_from_pass_md5( detail_url: &str, html: &str, requester: &mut Requester, ) -> Option { let pass_md5_url = Self::extract_pass_md5_url(html, detail_url).or_else(|| { Self::unpack_packer(html) .and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, detail_url)) })?; let headers = vec![ ("Referer".to_string(), detail_url.to_string()), ("X-Requested-With".to_string(), "XMLHttpRequest".to_string()), ("Accept".to_string(), "*/*".to_string()), ]; let response = requester .get_with_headers(&pass_md5_url, headers, None) .await .ok()?; Self::compose_pass_md5_media_url(&pass_md5_url, &response) } } impl crate::proxies::Proxy for DoodstreamProxy { async fn get_video_url(&self, url: String, requester: web::types::State) -> String { let Some(detail_url) = Self::normalize_detail_url(&url) else { return String::new(); }; let mut requester = requester.get_ref().clone(); let html = match requester .get_with_headers(&detail_url, Self::request_headers(&detail_url), None) .await { Ok(text) => text, Err(_) => return String::new(), }; if let Some(url) = Self::extract_stream_url(&html) { return url; } if let Some(url) = Self::resolve_stream_from_pass_md5(&detail_url, &html, &mut requester).await { return url; } String::new() } } #[cfg(test)] mod tests { use super::DoodstreamProxy; #[test] fn allows_only_known_doodstream_hosts() { assert!(DoodstreamProxy::is_allowed_detail_url( "https://turboplayers.xyz/t/69bdfb21cc640" )); assert!(DoodstreamProxy::is_allowed_detail_url( "https://trailerhg.xyz/e/ttdc7a6qpskt" )); assert!(!DoodstreamProxy::is_allowed_detail_url( "http://turboplayers.xyz/t/69bdfb21cc640" )); assert!(!DoodstreamProxy::is_allowed_detail_url( "https://example.com/t/69bdfb21cc640" )); } #[test] fn extracts_clear_hls_url_from_turboplayers_layout() { let html = r#"
"#; assert_eq!( DoodstreamProxy::extract_stream_url(html).as_deref(), Some("https://cdn4.turboviplay.com/data1/69bdfa8ce1f4d/69bdfa8ce1f4d.m3u8") ); } #[test] fn unpacks_streamhg_style_player_config() { let html = r#" "#; assert_eq!( DoodstreamProxy::extract_stream_url(html).as_deref(), Some("https://cdn.example/master.m3u8?t=1") ); } #[test] fn composes_media_url_from_pass_md5_response() { let pass_md5_url = "https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000"; let body = "https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt"; assert_eq!( DoodstreamProxy::compose_pass_md5_media_url(pass_md5_url, body).as_deref(), Some( "https://g4vsrqvtrj.pinebrookproductionlab.shop/1ghkpx2e8jnal/hls3/01/08534/syyzvotfnhaa_l/master.txt?token=t0k3n&expiry=1775000000" ) ); } #[test] fn extracts_relative_pass_md5_url() { let html = r#" "#; assert_eq!( DoodstreamProxy::extract_pass_md5_url(html, "https://trailerhg.xyz/e/ttdc7a6qpskt") .as_deref(), Some("https://trailerhg.xyz/pass_md5/abc123/def456?token=t0k3n&expiry=1775000000") ); } }