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,