diff --git a/src/providers/hqporner.rs b/src/providers/hqporner.rs index 31d1238..9bebcc6 100644 --- a/src/providers/hqporner.rs +++ b/src/providers/hqporner.rs @@ -188,7 +188,9 @@ impl HqpornerProvider { .await .map_err(|e| Error::from(format!("Request failed: {}", e)))?; - let video_items = self.get_video_items_from_html(text, &mut requester, &options).await; + let video_items = self + .get_video_items_from_html(text, &mut requester, &options) + .await; if !video_items.is_empty() { cache.insert(video_url, video_items.clone()); } @@ -234,7 +236,9 @@ impl HqpornerProvider { .await .map_err(|e| Error::from(format!("Request failed: {}", e)))?; - let video_items = self.get_video_items_from_html(text, &mut requester, &options).await; + let video_items = self + .get_video_items_from_html(text, &mut requester, &options) + .await; if !video_items.is_empty() { cache.insert(video_url, video_items.clone()); } diff --git a/src/providers/javtiful.rs b/src/providers/javtiful.rs index 07dbd5e..185b9df 100644 --- a/src/providers/javtiful.rs +++ b/src/providers/javtiful.rs @@ -356,7 +356,9 @@ impl JavtifulProvider { .unwrap_or("") .to_string(); let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32; - let (tags, formats, views) = self.extract_media(&video_url, &mut requester, options).await?; + let (tags, formats, views) = self + .extract_media(&video_url, &mut requester, options) + .await?; if preview.len() == 0 { preview = format!("https://trailers.jav.si/preview/{id}.mp4"); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index faa8121..2f57457 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -296,7 +296,11 @@ pub fn strip_url_scheme(url: &str) -> String { pub fn build_proxy_url(options: &ServerOptions, proxy: &str, target: &str) -> String { let target = target.trim_start_matches('/'); - let base = options.public_url_base.as_deref().unwrap_or("").trim_end_matches('/'); + let base = options + .public_url_base + .as_deref() + .unwrap_or("") + .trim_end_matches('/'); if base.is_empty() { format!("/proxy/{proxy}/{target}") diff --git a/src/providers/noodlemagazine.rs b/src/providers/noodlemagazine.rs index 38d294e..031ce5b 100644 --- a/src/providers/noodlemagazine.rs +++ b/src/providers/noodlemagazine.rs @@ -150,7 +150,10 @@ impl NoodlemagazineProvider { list.split("
") .skip(1) - .filter_map(|segment| self.get_video_item(segment.to_string(), proxy_base_url).ok()) + .filter_map(|segment| { + self.get_video_item(segment.to_string(), proxy_base_url) + .ok() + }) .collect() } diff --git a/src/providers/porn4fans.rs b/src/providers/porn4fans.rs index fd00a32..63c9242 100644 --- a/src/providers/porn4fans.rs +++ b/src/providers/porn4fans.rs @@ -49,6 +49,7 @@ impl Porn4fansProvider { fn sort_by(sort: &str) -> &'static str { match sort { + "popular" => "video_viewed", _ => "post_date", } } @@ -136,7 +137,8 @@ impl Porn4fansProvider { return Ok(old_items); } - let video_items = self.get_video_items_from_html(text); + let video_items = + self.get_video_items_from_html(text, options.public_url_base.as_deref().unwrap_or("")); if !video_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), video_items.clone()); @@ -193,7 +195,8 @@ impl Porn4fansProvider { return Ok(old_items); } - let video_items = self.get_video_items_from_html(text); + let video_items = + self.get_video_items_from_html(text, options.public_url_base.as_deref().unwrap_or("")); if !video_items.is_empty() { cache.remove(&video_url); cache.insert(video_url.clone(), video_items.clone()); @@ -229,6 +232,20 @@ impl Porn4fansProvider { format!("{}/{}", self.url, url.trim_start_matches("./")) } + fn proxy_url(&self, proxy_base_url: &str, url: &str) -> String { + let path = url + .strip_prefix(&self.url) + .unwrap_or(url) + .trim_start_matches('/'); + if proxy_base_url.is_empty() { + return format!("/proxy/porn4fans/{path}"); + } + format!( + "{}/proxy/porn4fans/{path}", + proxy_base_url.trim_end_matches('/') + ) + } + fn extract_thumb_url(&self, segment: &str) -> String { let thumb_raw = Self::first_non_empty_attr( segment, @@ -265,7 +282,7 @@ impl Porn4fansProvider { .and_then(|m| m.as_str().trim().parse::().ok()) } - fn get_video_items_from_html(&self, html: String) -> Vec { + fn get_video_items_from_html(&self, html: String, proxy_base_url: &str) -> Vec { if html.trim().is_empty() { return vec![]; } @@ -311,8 +328,14 @@ impl Porn4fansProvider { let views = Self::extract_views(body).unwrap_or(0); let rating = Self::extract_rating(body); - let mut item = - VideoItem::new(id, title, href, "porn4fans".to_string(), thumb, duration); + let mut item = VideoItem::new( + id, + title, + self.proxy_url(proxy_base_url, &href), + "porn4fans".to_string(), + thumb, + duration, + ); if views > 0 { item = item.views(views); } @@ -423,12 +446,12 @@ mod tests {
"##; - let items = provider.get_video_items_from_html(html.to_string()); + let items = provider.get_video_items_from_html(html.to_string(), "https://example.com"); assert_eq!(items.len(), 1); assert_eq!(items[0].id, "10194"); assert_eq!( items[0].url, - "https://www.porn4fans.com/video/10194/horny-police-officer-melztube-gets-banged-by-bbc/" + "https://example.com/proxy/porn4fans/video/10194/horny-police-officer-melztube-gets-banged-by-bbc/" ); assert_eq!( items[0].thumb, diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 9ceb998..f1f735f 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -1,6 +1,6 @@ use ntex::web; -use crate::proxies::noodlemagazine::NoodlemagazineProxy; +use crate::proxies::porn4fans::Porn4fansProxy; use crate::proxies::spankbang::SpankbangProxy; use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester}; @@ -8,15 +8,16 @@ pub mod hanimecdn; pub mod hqpornerthumb; pub mod javtiful; pub mod noodlemagazine; +pub mod porn4fans; pub mod spankbang; pub mod sxyprn; #[derive(Debug, Clone)] pub enum AnyProxy { + Porn4fans(Porn4fansProxy), Sxyprn(SxyprnProxy), Javtiful(javtiful::JavtifulProxy), Spankbang(SpankbangProxy), - Noodlemagazine(NoodlemagazineProxy), } pub trait Proxy { @@ -26,10 +27,10 @@ pub trait Proxy { impl Proxy for AnyProxy { async fn get_video_url(&self, url: String, requester: web::types::State) -> String { match self { + AnyProxy::Porn4fans(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::Spankbang(p) => p.get_video_url(url, requester).await, - AnyProxy::Noodlemagazine(p) => p.get_video_url(url, requester).await, } } } diff --git a/src/proxies/noodlemagazine.rs b/src/proxies/noodlemagazine.rs index 27267db..e981614 100644 --- a/src/proxies/noodlemagazine.rs +++ b/src/proxies/noodlemagazine.rs @@ -1,5 +1,7 @@ -use ntex::web; +use ntex::http::header::CONTENT_TYPE; +use ntex::web::{self, HttpRequest, error}; use serde_json::Value; +use url::Url; use wreq::Version; use crate::util::requester::Requester; @@ -47,33 +49,141 @@ impl NoodlemagazineProxy { .map(str::to_string) } - pub async fn get_video_url( + fn normalize_video_page_url(url: &str) -> String { + if url.starts_with("http://") || url.starts_with("https://") { + url.to_string() + } else { + format!("https://{}", url.trim_start_matches('/')) + } + } + + fn is_hls_url(url: &str) -> bool { + Url::parse(url) + .ok() + .map(|parsed| parsed.path().ends_with(".m3u8")) + .unwrap_or(false) + } + + fn absolutize_uri(base_url: &Url, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + + if value.starts_with('#') + || value.starts_with("data:") + || value.starts_with("http://") + || value.starts_with("https://") + { + return value.to_string(); + } + + base_url + .join(value) + .map(|url| url.to_string()) + .unwrap_or_else(|_| value.to_string()) + } + + fn rewrite_manifest_line(base_url: &Url, line: &str) -> String { + if line.trim().is_empty() { + return line.to_string(); + } + + if !line.starts_with('#') { + return Self::absolutize_uri(base_url, line); + } + + let Some(uri_start) = line.find("URI=\"") else { + return line.to_string(); + }; + let value_start = uri_start + 5; + let Some(relative_end) = line[value_start..].find('"') else { + return line.to_string(); + }; + let value_end = value_start + relative_end; + let value = &line[value_start..value_end]; + let rewritten = Self::absolutize_uri(base_url, value); + + format!( + "{}{}{}", + &line[..value_start], + rewritten, + &line[value_end..] + ) + } + + fn rewrite_manifest(manifest_url: &str, body: &str) -> Option { + let base_url = Url::parse(manifest_url).ok()?; + + Some( + body.lines() + .map(|line| Self::rewrite_manifest_line(&base_url, line)) + .collect::>() + .join("\n"), + ) + } + + async fn resolve_source_url( &self, url: String, requester: web::types::State, - ) -> String { + ) -> Option<(String, String)> { let mut requester = requester.get_ref().clone(); - let url = if url.starts_with("http://") || url.starts_with("https://") { - url - } else { - format!("https://{}", url.trim_start_matches('/')) - }; + let url = Self::normalize_video_page_url(&url); let text = requester .get(&url, Some(Version::HTTP_2)) .await .unwrap_or_default(); if text.is_empty() { - return String::new(); + return None; } let Some(playlist) = Self::extract_playlist(&text) else { - return String::new(); + return None; }; - Self::select_best_source(playlist).unwrap_or_default() + Self::select_best_source(playlist).map(|source_url| (url, source_url)) } } +pub async fn serve_media( + req: HttpRequest, + requester: web::types::State, +) -> Result { + let endpoint = req.match_info().query("endpoint").to_string(); + let proxy = NoodlemagazineProxy::new(); + let Some((video_page_url, source_url)) = + proxy.resolve_source_url(endpoint, requester.clone()).await + else { + return Ok(web::HttpResponse::BadGateway().finish()); + }; + + if !NoodlemagazineProxy::is_hls_url(&source_url) { + return Ok(web::HttpResponse::Found() + .header("Location", source_url) + .finish()); + } + + let mut upstream_requester = requester.get_ref().clone(); + let upstream = match upstream_requester + .get_raw_with_headers(&source_url, vec![("Referer".to_string(), video_page_url)]) + .await + { + Ok(response) => response, + Err(_) => return Ok(web::HttpResponse::BadGateway().finish()), + }; + + let manifest_body = upstream.text().await.map_err(error::ErrorBadGateway)?; + let rewritten_manifest = + match NoodlemagazineProxy::rewrite_manifest(&source_url, &manifest_body) { + Some(body) => body, + None => return Ok(web::HttpResponse::BadGateway().finish()), + }; + + Ok(web::HttpResponse::Ok() + .header(CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(rewritten_manifest)) +} + #[cfg(test)] mod tests { use super::NoodlemagazineProxy; @@ -107,4 +217,18 @@ mod tests { Some("https://cdn.example/master.m3u8") ); } + + #[test] + fn rewrites_manifest_to_direct_absolute_urls() { + let manifest = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nlow/index.m3u8\n#EXT-X-KEY:METHOD=AES-128,URI=\"keys/key.bin\"\nsegment0.ts"; + + let rewritten = + NoodlemagazineProxy::rewrite_manifest("https://cdn.example/hls/master.m3u8", manifest) + .unwrap(); + + assert_eq!( + rewritten, + "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nhttps://cdn.example/hls/low/index.m3u8\n#EXT-X-KEY:METHOD=AES-128,URI=\"https://cdn.example/hls/keys/key.bin\"\nhttps://cdn.example/hls/segment0.ts" + ); + } } diff --git a/src/proxies/porn4fans.rs b/src/proxies/porn4fans.rs new file mode 100644 index 0000000..e075dff --- /dev/null +++ b/src/proxies/porn4fans.rs @@ -0,0 +1,146 @@ +use ntex::web; +use regex::Regex; + +use crate::util::requester::Requester; + +#[derive(Debug, Clone)] +pub struct Porn4fansProxy {} + +impl Porn4fansProxy { + pub fn new() -> Self { + Porn4fansProxy {} + } + + fn request_headers() -> Vec<(String, String)> { + vec![( + "Referer".to_string(), + "https://www.porn4fans.com/".to_string(), + )] + } + + fn normalize_page_url(url: &str) -> String { + if url.starts_with("http://") || url.starts_with("https://") { + return url.to_string(); + } + + let trimmed = url.trim_start_matches('/'); + if trimmed.starts_with("www.porn4fans.com/") || trimmed.starts_with("porn4fans.com/") { + return format!("https://{trimmed}"); + } + + format!("https://www.porn4fans.com/{trimmed}") + } + + fn decode_escaped_text(text: &str) -> String { + text.replace("\\/", "/").replace("&", "&") + } + + fn extract_preferred_video_url(text: &str) -> Option { + let decoded = Self::decode_escaped_text(text); + let video_url_re = Regex::new( + r#"(?is)(?:^|[{\s,])["']?video_url["']?\s*[:=]\s*["'](?Phttps?://[^"'<>]+?\.mp4/?(?:\?[^"'<>]*)?)["']"#, + ) + .ok()?; + + if let Some(url) = video_url_re + .captures(&decoded) + .and_then(|captures| captures.name("url")) + .map(|value| value.as_str().to_string()) + { + return Some(url); + } + + let generic_mp4_re = Regex::new( + r#"(?is)(?Phttps?://[^"'<>\s]+/get_file/[^"'<>\s]+?\.mp4/?(?:\?[^"'<>]*)?)"#, + ) + .ok()?; + + generic_mp4_re + .captures(&decoded) + .and_then(|captures| captures.name("url")) + .map(|value| value.as_str().to_string()) + } + + fn extract_rnd(text: &str) -> Option { + let decoded = Self::decode_escaped_text(text); + let rnd_re = + Regex::new(r#"(?is)(?:^|[{\s,])["']?rnd["']?\s*[:=]\s*["']?(?P\d{8,})"#).ok()?; + + rnd_re + .captures(&decoded) + .and_then(|captures| captures.name("rnd")) + .map(|value| value.as_str().to_string()) + } + + fn attach_rnd(url: String, rnd: Option) -> String { + if url.is_empty() || url.contains("rnd=") { + return url; + } + + let Some(rnd) = rnd else { + return url; + }; + + let separator = if url.contains('?') { '&' } else { '?' }; + format!("{url}{separator}rnd={rnd}") + } + + pub async fn get_video_url( + &self, + url: String, + requester: web::types::State, + ) -> String { + let mut requester = requester.get_ref().clone(); + let page_url = Self::normalize_page_url(&url); + let text = requester + .get_with_headers(&page_url, Self::request_headers(), None) + .await + .unwrap_or_default(); + + if text.is_empty() { + return String::new(); + } + + let Some(video_url) = Self::extract_preferred_video_url(&text) else { + return String::new(); + }; + + Self::attach_rnd(video_url, Self::extract_rnd(&text)) + } +} + +#[cfg(test)] +mod tests { + use super::Porn4fansProxy; + + #[test] + fn extracts_video_url_and_appends_rnd() { + let html = r#" + + "#; + + let video_url = Porn4fansProxy::extract_preferred_video_url(html).unwrap(); + assert_eq!( + Porn4fansProxy::attach_rnd(video_url, Porn4fansProxy::extract_rnd(html)), + "https://www.porn4fans.com/get_file/3/9df8de1fc2da5dfcbf9a4ad512dc8f306c4997e60f/10000/10951/10951.mp4/?rnd=1773402926076" + ); + } + + #[test] + fn normalizes_relative_proxy_target() { + assert_eq!( + Porn4fansProxy::normalize_page_url("video/10951/example/"), + "https://www.porn4fans.com/video/10951/example/" + ); + assert_eq!( + Porn4fansProxy::normalize_page_url("www.porn4fans.com/video/10951/example/"), + "https://www.porn4fans.com/video/10951/example/" + ); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index 861db62..b78200f 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,7 +1,7 @@ use ntex::web::{self, HttpRequest}; use crate::proxies::javtiful::JavtifulProxy; -use crate::proxies::noodlemagazine::NoodlemagazineProxy; +use crate::proxies::porn4fans::Porn4fansProxy; use crate::proxies::spankbang::SpankbangProxy; use crate::proxies::sxyprn::SxyprnProxy; use crate::proxies::*; @@ -24,10 +24,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::get().to(proxy2redirect)), ) .service( - web::resource("/noodlemagazine/{endpoint}*") + web::resource("/porn4fans/{endpoint}*") .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) + .service( + web::resource("/noodlemagazine/{endpoint}*") + .route(web::post().to(crate::proxies::noodlemagazine::serve_media)) + .route(web::get().to(crate::proxies::noodlemagazine::serve_media)), + ) .service( web::resource("/hanime-cdn/{endpoint}*") .route(web::post().to(crate::proxies::hanimecdn::get_image)) @@ -57,10 +62,10 @@ async fn proxy2redirect( fn get_proxy(proxy: &str) -> Option { match proxy { + "porn4fans" => Some(AnyProxy::Porn4fans(Porn4fansProxy::new())), "sxyprn" => Some(AnyProxy::Sxyprn(SxyprnProxy::new())), "javtiful" => Some(AnyProxy::Javtiful(JavtifulProxy::new())), "spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())), - "noodlemagazine" => Some(AnyProxy::Noodlemagazine(NoodlemagazineProxy::new())), _ => None, } } diff --git a/src/videos.rs b/src/videos.rs index 56b122f..e385e3c 100644 --- a/src/videos.rs +++ b/src/videos.rs @@ -90,9 +90,9 @@ pub struct VideoItem { pub views: Option, // 14622653, #[serde(skip_serializing_if = "Option::is_none")] pub rating: Option, // 0.0, - pub id: String, // "c85017ca87477168d648727753c4ded8a35f173e22ef93743e707b296becb299", + pub id: String, // "c85017ca87477168d648727753c4ded8a35f173e22ef93743e707b296becb299", pub title: String, // "20 Minutes of Adorable Kittens BEST Compilation", - pub url: String, // "https://www.youtube.com/watch?v=y0sF5xhGreA", + pub url: String, // "https://www.youtube.com/watch?v=y0sF5xhGreA", pub channel: String, // "youtube", pub thumb: String, // "https://i.ytimg.com/vi/y0sF5xhGreA/hqdefault.jpg", #[serde(skip_serializing_if = "Option::is_none")] @@ -127,8 +127,8 @@ impl VideoItem { VideoItem { duration: duration, // Placeholder, adjust as needed isLive: false, - views: None, // Placeholder, adjust as needed - rating: None, // Placeholder, adjust as needed + views: None, // Placeholder, adjust as needed + rating: None, // Placeholder, adjust as needed id, title, url,