Add fullporner provider with xiaoshenke proxy

- Implement fullporner.com provider with search, pagination, categories, pornstars
- Add xiaoshenke.net proxy for Cloudflare-protected embed videos
- Proxy fetches signed URLs via FlareSolverr and streams video content
- Fix quality bitmask mapping (1=360p, 2=480p, 4=720p, 8=1080p)
- Support HEAD/GET/Range for video streaming

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Simon
2026-10-08 17:33:09 +00:00
parent ea567a791e
commit e204202611
12 changed files with 1039 additions and 43 deletions

View File

@@ -46,6 +46,7 @@ cbc = { version = "0.1", features = ["alloc"] }
hex = "0.4"
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
playwright-rs = "0.17"
http-body-util = "0.1.5"
[lints.rust]
warnings = "warn"
@@ -68,4 +69,4 @@ incremental = true
opt-level = 3
codegen-units = 16
lto = false
debug = 0
debug = 0

View File

@@ -361,6 +361,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "fullporner",
ty: "FullpornerProvider",
},
ProviderDef {
id: "fullpornerorg",
module: "fullpornerorg",
ty: "FullpornerorgProvider",
},
ProviderDef {
id: "thepornbunny",
module: "thepornbunny",

View File

@@ -16,11 +16,8 @@ pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
};
const BASE_URL: &str = "https://fullporner.com";
const MEDIA_HOST: &str = "https://xiaoshenke.net";
const CHANNEL_ID: &str = "fullporner";
const DEFAULT_PER_PAGE: usize = 32;
const BROWSER_UA: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0";
error_chain! {
foreign_links {
@@ -262,17 +259,68 @@ impl FullpornerProvider {
Ok(stubs)
}
fn build_format(thumb_id: &str) -> VideoFormat {
let url = format!("{MEDIA_HOST}/vid/{thumb_id}/720");
let mut fmt = VideoFormat::new(url, "720p".to_string(), "mp4".to_string());
fmt.add_http_header("Referer".to_string(), format!("{MEDIA_HOST}/"));
fmt.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
fmt
/// Extract xiaoshenke embed URL from the watch page.
/// Returns (video_id, quality_bitmask) from iframe src like "//xiaoshenke.net/video/3827433/14"
fn extract_embed_params(watch_html: &str) -> Option<(String, u8)> {
let document = Html::parse_document(watch_html);
let iframe_sel = Selector::parse("iframe[src*=\"xiaoshenke.net/video/\"]").ok()?;
for iframe in document.select(&iframe_sel) {
if let Some(src) = iframe.value().attr("src") {
// src format: //xiaoshenke.net/video/{id}/{quality_bitmask}
let parts: Vec<&str> = src.split('/').collect();
if parts.len() >= 5 && parts[2] == "xiaoshenke.net" && parts[3] == "video" {
let video_id = parts[4].to_string();
let quality_bitmask = parts[5].parse::<u8>().ok()?;
return Some((video_id, quality_bitmask));
}
}
}
None
}
/// Fetch watch page and extract embed params, then build proxy URL for formats.
async fn enrich_with_formats(
mut item: VideoItem,
options: &ServerOptions,
) -> VideoItem {
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_with_formats");
let html = match requester.get(&item.url, None).await {
Ok(html) => html,
Err(_) => return item, // Return item without formats on fetch failure
};
if let Some((video_id, quality_bitmask)) = Self::extract_embed_params(&html) {
let proxy_url = format!(
"http://127.0.0.1:18080/proxy/xiaoshenke/video/{}/{}",
video_id, quality_bitmask
);
let height = Self::quality_bitmask_to_height(quality_bitmask);
item.formats = Some(vec![VideoFormat::new(
proxy_url,
format!("{}p", height),
"mp4".to_string(),
)]);
}
item
}
fn quality_bitmask_to_height(bitmask: u8) -> u32 {
if bitmask & 8 != 0 {
1080
} else if bitmask & 4 != 0 {
720
} else if bitmask & 2 != 0 {
480
} else if bitmask & 1 != 0 {
360
} else {
720
}
}
fn stub_to_item(stub: CardStub) -> VideoItem {
let url = format!("{BASE_URL}/watch/{}", stub.slug);
let formats = stub.thumb_id.as_deref().map(|id| vec![Self::build_format(id)]);
let mut item = VideoItem::new(
stub.slug,
@@ -282,7 +330,6 @@ impl FullpornerProvider {
stub.thumb,
stub.duration,
);
item.formats = formats;
item.uploadedAt = stub.uploaded_at;
item.aspectRatio = Some(16.0 / 9.0);
item
@@ -301,11 +348,24 @@ impl FullpornerProvider {
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))?;
let stubs = Self::parse_listing_page(&html)?;
let items = stubs
.into_iter()
.take(per_page)
.map(Self::stub_to_item)
.collect();
let mut items = Vec::new();
for stub in stubs {
// Only include items that have a valid thumb_id (i.e., playable formats)
// Old thumbnail format (/imgs/.../xxx_main.jpg) doesn't yield a valid video ID
if stub.thumb_id.is_none() {
continue;
}
let item = Self::stub_to_item(stub);
let item = Self::enrich_with_formats(item, options).await;
items.push(item);
if items.len() >= per_page {
break;
}
}
Ok(items)
}
}

View File

@@ -0,0 +1,542 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, report_provider_error, report_provider_error_background, requester_or_default,
};
use crate::status::*;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use regex::Regex;
use scraper::{Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["tube", "full-length", "embed", "lulustream"],
};
const BASE_URL: &str = "https://fullporner.org";
const CHANNEL_ID: &str = "fullpornerorg";
const DEFAULT_PER_PAGE: usize = 24;
const ENRICH_CONCURRENCY: usize = 4;
const BROWSER_UA: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/124.0.0.0 Safari/537.36";
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct FullpornerorgProvider;
#[derive(Debug, Clone)]
enum Target {
Latest,
Search { query: String },
Pornstar { slug: String },
}
#[derive(Debug, Clone)]
struct CardStub {
id: String,
title: String,
url: String,
thumb: String,
duration: u32,
actor_slugs: Vec<String>,
}
impl FullpornerorgProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: CHANNEL_ID.to_string(),
name: "FullPorner.org".to_string(),
description:
"Full length porn videos from fullporner.org. Uses lulustream embeds; \
HLS URLs extracted via the lulustream proxy."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=fullporner.org".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse FullPorner.org by newest videos.".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
},
],
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|error| Error::from(format!("selector `{value}` failed: {error}")))
}
fn pick_target(query: Option<&str>) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
if let Some(slug) = q.strip_prefix("pornstar:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
};
}
if let Some(slug) = q.strip_prefix("star:") {
return Target::Pornstar {
slug: slug.trim().to_string(),
};
}
return Target::Search {
query: q.to_string(),
};
}
Target::Latest
}
fn build_listing_url(target: &Target, page: u16) -> String {
let page = page.max(1);
match target {
Target::Latest => {
if page <= 1 {
format!("{BASE_URL}/")
} else {
format!("{BASE_URL}/page/{page}/")
}
}
Target::Search { query } => {
let encoded: String =
url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
if page <= 1 {
format!("{BASE_URL}/?s={encoded}")
} else {
format!("{BASE_URL}/page/{page}/?s={encoded}")
}
}
Target::Pornstar { slug } => {
if page <= 1 {
format!("{BASE_URL}/videos/pornstar/{slug}/")
} else {
format!("{BASE_URL}/videos/pornstar/{slug}/page/{page}/")
}
}
}
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("User-Agent".to_string(), BROWSER_UA.to_string()),
("Referer".to_string(), referer.to_string()),
]
}
async fn fetch_html(options: &ServerOptions, url: &str, referer: &str) -> Result<String> {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_html");
requester
.get_with_headers(url, Self::html_headers(referer), None)
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
}
fn parse_listing_page(html: &str) -> Result<Vec<CardStub>> {
let document = Html::parse_document(html);
// Video cards are article elements with data-video-id attribute
let card_sel = Self::selector("article.loop-video[data-video-id]")?;
let link_sel = Self::selector("a[href]")?;
let thumb_attr_sel = Self::selector("[data-main-thumb]")?;
let title_sel = Self::selector("header.entry-header h2, header.entry-header .entry-title a")?;
let duration_sel = Self::selector(".duration")?;
let actor_regex = Regex::new(r"\bactors-([a-z0-9-]+)")
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
let mut stubs = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for card in document.select(&card_sel) {
let class_attr = card.value().attr("class").unwrap_or_default();
let id = class_attr
.split_whitespace()
.find_map(|c| c.strip_prefix("post-"))
.map(str::to_string)
.or_else(|| {
card.value()
.attr("data-post-id")
.map(str::to_string)
})
.unwrap_or_default();
if id.is_empty() || !seen_ids.insert(id.clone()) {
continue;
}
let url = card
.select(&link_sel)
.next()
.and_then(|a| a.value().attr("href"))
.map(str::to_string)
.unwrap_or_default();
if url.is_empty() {
continue;
}
let title = card
.select(&title_sel)
.next()
.map(|s| s.text().collect::<Vec<_>>().join(""))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| id.clone());
let thumb = card
.select(&thumb_attr_sel)
.next()
.and_then(|el| el.value().attr("data-main-thumb"))
.map(str::to_string)
.filter(|t| !t.is_empty())
.or_else(|| {
card.select(&Self::selector("img[src]").ok()?)
.next()
.and_then(|img| img.value().attr("src"))
.map(str::to_string)
})
.unwrap_or_default();
let duration_text = card
.select(&duration_sel)
.next()
.map(|s| s.text().collect::<Vec<_>>().join(""))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.unwrap_or_default();
let duration = crate::util::time::parse_time_to_seconds(&duration_text)
.and_then(|s| u32::try_from(s).ok())
.unwrap_or(0);
// Extract actor slugs from class names like "actors-vivienne-vo"
let actor_slugs: Vec<String> = actor_regex
.captures_iter(class_attr)
.filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.collect();
stubs.push(CardStub {
id,
title,
url,
thumb,
duration,
actor_slugs,
});
}
Ok(stubs)
}
/// Extracts the luluvdo.com embed URL from a fullporner.org detail page.
fn extract_embed_url(html: &str) -> Option<String> {
// The embed is in an iframe: <iframe src="https://luluvdo.com/e/VIDEO_ID" ...>
let needle = "luluvdo.com/e/";
let needle_idx = html.find(needle)?;
// Walk back to the start of the URL (find `https://` or `http://`).
let head = &html[..=needle_idx];
let scheme_idx = head
.rfind("https://")
.or_else(|| head.rfind("http://"))?;
let after_scheme = &html[scheme_idx..];
// End of the URL: the next quote, space, or `>`.
let end = after_scheme
.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '>')
.unwrap_or(after_scheme.len());
Some(after_scheme[..end].to_string())
}
/// Extracts the embed ID from a `https://luluvdo.com/e/{id}` or `https://luluvid.com/e/{id}` URL.
fn extract_embed_id(embed_url: &str) -> Option<String> {
let prefixes = ["luluvdo.com/e/", "luluvid.com/e/", "lulustream.com/e/"];
for prefix in prefixes {
if let Some(idx) = embed_url.find(prefix) {
let after = &embed_url[idx + prefix.len()..];
let end = after
.find(|c: char| c == '?' || c == '/' || c == '"' || c == '\'')
.unwrap_or(after.len());
if end > 0 {
return Some(after[..end].to_string());
}
}
}
None
}
/// Builds the lulustream proxy URL for a given embed ID.
fn build_proxy_url(embed_id: &str, options: &ServerOptions) -> String {
crate::providers::build_proxy_url(options, "lulustream", embed_id)
}
async fn enrich_card(stub: CardStub, options: &ServerOptions) -> Result<VideoItem> {
// Step 1: fetch the detail page to find the luluvdo embed.
let detail_html = Self::fetch_html(options, &stub.url, BASE_URL).await?;
let embed_url = Self::extract_embed_url(&detail_html)
.ok_or_else(|| Error::from(format!("no luluvdo embed in {}", stub.url)))?;
let embed_id = Self::extract_embed_id(&embed_url)
.ok_or_else(|| Error::from(format!("could not parse embed id from {embed_url}")))?;
// Step 2: build the proxy URL (will hit /proxy/lulustream/{embed_id} which
// resolves the actual HLS URL via the lulustream proxy).
let proxy_url = Self::build_proxy_url(&embed_id, options);
// Build the format. The proxy will return a redirect to the actual HLS URL.
let mut format = VideoFormat::m3u8(proxy_url, "auto".to_string(), "m3u8".to_string());
format.add_http_header("Referer".to_string(), BASE_URL.to_string());
format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
let mut item = VideoItem::new(
stub.id,
stub.title,
stub.url,
CHANNEL_ID.to_string(),
stub.thumb,
stub.duration,
);
item.formats = Some(vec![format]);
if !stub.actor_slugs.is_empty() {
item.tags = Some(stub.actor_slugs);
}
Ok(item)
}
async fn fetch_page(
target: Target,
page: u16,
per_page: usize,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::build_listing_url(&target, page);
let html = Self::fetch_html(options, &url, BASE_URL).await?;
let stubs = Self::parse_listing_page(&html)?;
if stubs.is_empty() {
return Ok(vec![]);
}
let limited: Vec<_> = stubs.into_iter().take(per_page).collect();
let options = options.clone();
let items = stream::iter(limited.into_iter().map(|stub| {
let options = options.clone();
async move {
match Self::enrich_card(stub, &options).await {
Ok(item) => Some(item),
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"fetch_page.enrich_card",
&error.to_string(),
);
None
}
}
}
}))
.buffer_unordered(ENRICH_CONCURRENCY)
.filter_map(async move |v| v)
.collect::<Vec<_>>()
.await;
Ok(items)
}
}
#[async_trait]
impl Provider for FullpornerorgProvider {
async fn get_videos(
&self,
_cache: crate::util::cache::VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u16>().unwrap_or(1).max(1);
let per_page = per_page
.parse::<usize>()
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, 48);
let normalized_query = query
.as_deref()
.map(str::trim)
.filter(|q| !q.is_empty())
.map(ToOwned::to_owned);
let target = Self::pick_target(normalized_query.as_deref());
match Self::fetch_page(target, page, per_page, &options).await {
Ok(items) => items,
Err(error) => {
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_listing_urls() {
assert_eq!(
FullpornerorgProvider::build_listing_url(&Target::Latest, 1),
"https://fullporner.org/"
);
assert_eq!(
FullpornerorgProvider::build_listing_url(&Target::Latest, 2),
"https://fullporner.org/page/2/"
);
assert_eq!(
FullpornerorgProvider::build_listing_url(
&Target::Search {
query: "milf".to_string()
},
1
),
"https://fullporner.org/?s=milf"
);
assert_eq!(
FullpornerorgProvider::build_listing_url(
&Target::Search {
query: "teen".to_string()
},
2
),
"https://fullporner.org/page/2/?s=teen"
);
assert_eq!(
FullpornerorgProvider::build_listing_url(
&Target::Pornstar {
slug: "vivienne-vo".to_string()
},
1
),
"https://fullporner.org/videos/pornstar/vivienne-vo/"
);
assert_eq!(
FullpornerorgProvider::build_listing_url(
&Target::Pornstar {
slug: "vivienne-vo".to_string()
},
2
),
"https://fullporner.org/videos/pornstar/vivienne-vo/page/2/"
);
}
#[test]
fn picks_search_for_query() {
match FullpornerorgProvider::pick_target(Some("test")) {
Target::Search { query } => assert_eq!(query, "test"),
other => panic!("expected Search, got {:?}", other),
}
}
#[test]
fn picks_latest_for_no_query() {
assert!(matches!(
FullpornerorgProvider::pick_target(None),
Target::Latest
));
assert!(matches!(
FullpornerorgProvider::pick_target(Some("")),
Target::Latest
));
assert!(matches!(
FullpornerorgProvider::pick_target(Some(" ")),
Target::Latest
));
}
#[test]
fn picks_pornstar_for_prefix() {
match FullpornerorgProvider::pick_target(Some("pornstar:vivienne-vo")) {
Target::Pornstar { slug, .. } => assert_eq!(slug, "vivienne-vo"),
other => panic!("expected Pornstar, got {:?}", other),
}
match FullpornerorgProvider::pick_target(Some("star:vivienne-vo")) {
Target::Pornstar { slug, .. } => assert_eq!(slug, "vivienne-vo"),
other => panic!("expected Pornstar, got {:?}", other),
}
}
#[test]
fn extracts_embed_id_from_luluvdo_url() {
assert_eq!(
FullpornerorgProvider::extract_embed_id("https://luluvdo.com/e/a0jb2i5304d4"),
Some("a0jb2i5304d4".to_string())
);
assert_eq!(
FullpornerorgProvider::extract_embed_id("https://luluvid.com/e/a0jb2i5304d4"),
Some("a0jb2i5304d4".to_string())
);
assert_eq!(
FullpornerorgProvider::extract_embed_id("https://lulustream.com/e/a0jb2i5304d4"),
Some("a0jb2i5304d4".to_string())
);
assert_eq!(
FullpornerorgProvider::extract_embed_id("https://luluvdo.com/e/a0jb2i5304d4?foo=bar"),
Some("a0jb2i5304d4".to_string())
);
assert_eq!(
FullpornerorgProvider::extract_embed_id("https://other.com/e/abc"),
None
);
}
#[test]
fn extracts_embed_url_from_detail_html() {
let html = r#"<iframe src="https://luluvdo.com/e/a0jb2i5304d4" scrolling="no" frameborder="0"></iframe>"#;
assert_eq!(
FullpornerorgProvider::extract_embed_url(html).as_deref(),
Some("https://luluvdo.com/e/a0jb2i5304d4")
);
// Also test uppercase attributes
let html2 = r#"<IFRAME SRC="https://luluvdo.com/e/abc123" FRAMEBORDER=0>"#;
assert_eq!(
FullpornerorgProvider::extract_embed_url(html2).as_deref(),
Some("https://luluvdo.com/e/abc123")
);
}
}

View File

@@ -187,7 +187,7 @@ async fn validate_media_response(
let mut requester = requester;
let response = requester
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT), None)
.await
.map_err(|err| {
format!(
@@ -1231,7 +1231,7 @@ mod tests {
let mut requester = Requester::new();
let response = requester
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT), None)
.await
.map_err(|err| {
format!(

View File

@@ -1928,6 +1928,7 @@ mod tests {
&first.thumb,
vec![("Range".to_string(), "bytes=0-1023".to_string())],
Some(Duration::from_secs(30)),
None,
)
.await
.expect("thumbnail request should succeed");

View File

@@ -234,6 +234,7 @@ impl ArchivebateProxy {
embed_url,
Self::html_headers(detail_url),
Some(StdDuration::from_secs(8)),
None,
)
.await
.ok()?;

View File

@@ -16,21 +16,24 @@ impl LulustreamProxy {
LulustreamProxy {}
}
fn normalize_detail_request(endpoint: &str) -> Option<(String, String)> {
fn normalize_detail_request(endpoint: &str) -> Option<(String, String, String)> {
let endpoint = endpoint.trim().trim_start_matches('/');
if endpoint.is_empty() {
return None;
}
let detail_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
let (detail_url, host) = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
(endpoint.to_string(), None)
} else if endpoint.starts_with("lulustream.com/")
|| endpoint.starts_with("www.lulustream.com/")
|| endpoint.starts_with("luluvid.com/")
{
format!("https://{endpoint}")
(format!("https://{endpoint}"), Some("lulustream.com"))
} else if endpoint.starts_with("luluvid.com/") {
(format!("https://{endpoint}"), Some("luluvid.com"))
} else if endpoint.starts_with("luluvdo.com/") {
(format!("https://{endpoint}"), Some("luluvdo.com"))
} else {
format!("https://lulustream.com/{endpoint}")
(format!("https://lulustream.com/{endpoint}"), Some("lulustream.com"))
};
if !Self::is_allowed_detail_url(&detail_url) {
@@ -40,7 +43,13 @@ impl LulustreamProxy {
let parsed = Url::parse(&detail_url).ok()?;
let video_id = parsed.path_segments()?.last().map(ToOwned::to_owned)?;
Some((detail_url, video_id))
// Determine embed host from URL or default to luluvid.com
let embed_host = host
.map(String::from)
.or_else(|| parsed.host_str().map(String::from))
.unwrap_or_else(|| "luluvid.com".to_string());
Some((detail_url, video_id, embed_host))
}
fn is_allowed_detail_url(url: &str) -> bool {
@@ -53,7 +62,7 @@ impl LulustreamProxy {
let Some(host) = parsed.host_str() else {
return false;
};
(host == "lulustream.com" || host == "www.lulustream.com" || host == "luluvid.com")
(host == "lulustream.com" || host == "www.lulustream.com" || host == "luluvid.com" || host == "luluvdo.com")
&& !parsed.path().is_empty()
&& parsed.path() != "/"
}
@@ -114,11 +123,11 @@ impl LulustreamProxy {
impl crate::proxies::Proxy for LulustreamProxy {
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
let Some((_detail_url, video_id)) = Self::normalize_detail_request(&url) else {
let Some((_detail_url, video_id, embed_host)) = Self::normalize_detail_request(&url) else {
return String::new();
};
let embed_url = format!("https://luluvid.com/e/{video_id}");
let embed_url = format!("https://{embed_host}/e/{video_id}");
// Chrome120 emulation bypasses Cloudflare; try it first.
if let Some(media_url) = Self::try_chrome_extraction(&embed_url).await {
@@ -138,28 +147,41 @@ mod tests {
#[test]
fn normalizes_detail_request_with_full_url() {
let (url, video_id) =
let (url, video_id, embed_host) =
LulustreamProxy::normalize_detail_request("https://lulustream.com/d/s484n23k8opy")
.expect("detail request should parse");
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
assert_eq!(video_id, "s484n23k8opy");
assert_eq!(embed_host, "lulustream.com");
}
#[test]
fn normalizes_detail_request_with_path_only() {
let (url, video_id) = LulustreamProxy::normalize_detail_request("d/s484n23k8opy")
let (url, video_id, embed_host) = LulustreamProxy::normalize_detail_request("d/s484n23k8opy")
.expect("detail request should parse");
assert_eq!(url, "https://lulustream.com/d/s484n23k8opy");
assert_eq!(video_id, "s484n23k8opy");
assert_eq!(embed_host, "lulustream.com");
}
#[test]
fn normalizes_luluvid_url() {
let (url, video_id) =
let (url, video_id, embed_host) =
LulustreamProxy::normalize_detail_request("https://luluvid.com/e/s484n23k8opy")
.expect("detail request should parse");
assert_eq!(url, "https://luluvid.com/e/s484n23k8opy");
assert_eq!(video_id, "s484n23k8opy");
assert_eq!(embed_host, "luluvid.com");
}
#[test]
fn normalizes_luluvdo_url() {
let (url, video_id, embed_host) =
LulustreamProxy::normalize_detail_request("https://luluvdo.com/e/s484n23k8opy")
.expect("detail request should parse");
assert_eq!(url, "https://luluvdo.com/e/s484n23k8opy");
assert_eq!(video_id, "s484n23k8opy");
assert_eq!(embed_host, "luluvdo.com");
}
#[test]

View File

@@ -49,6 +49,7 @@ pub mod tube8;
pub mod thepornbunny;
pub mod vidara;
pub mod vjav;
pub mod xiaoshenke;
#[derive(Debug, Clone)]
pub enum AnyProxy {

296
src/proxies/xiaoshenke.rs Normal file
View File

@@ -0,0 +1,296 @@
use std::sync::Arc;
use futures::StreamExt;
use http_body_util::BodyExt;
use ntex::http::Method;
use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
use ntex::util::Bytes;
use ntex::web::{self, HttpRequest};
use crate::util::flaresolverr::{FlareSolverrRequest, Flaresolverr};
use crate::util::requester::Requester;
const FIREFOX_USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
const HTML_ACCEPT: &str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
#[derive(Debug, Clone)]
pub struct XiaoshenkeProxy {}
impl XiaoshenkeProxy {
pub fn new() -> Self {
XiaoshenkeProxy {}
}
/// Extract the video ID and quality bitmask from the embed page URL.
/// Embed URL format: //xiaoshenke.net/video/{id}/{quality_bitmask}
/// Quality bitmask: 1=360p, 2=480p, 4=720p, 8=1080p (bitwise flags)
fn parse_embed_url(endpoint: &str) -> Option<(String, u8)> {
let endpoint = endpoint.trim().trim_start_matches('/');
if !endpoint.starts_with("video/") {
return None;
}
let parts: Vec<&str> = endpoint.split('/').collect();
if parts.len() != 3 {
return None;
}
let id = parts[1].to_string();
let quality_bitmask = parts[2].parse::<u8>().ok()?;
Some((id, quality_bitmask))
}
/// Quality bitmask to height mapping (picks highest available quality)
/// Bitmask: 1=360p, 2=480p, 4=720p, 8=1080p
fn quality_bitmask_to_height(bitmask: u8) -> u32 {
if bitmask & 8 != 0 {
1080
} else if bitmask & 4 != 0 {
720
} else if bitmask & 2 != 0 {
480
} else if bitmask & 1 != 0 {
360
} else {
720 // default
}
}
/// Reverse the video ID (as done in the embed page JavaScript)
fn reverse_id(id: &str) -> String {
id.chars().rev().collect()
}
/// Build the video CDN URL from reversed ID and quality
fn build_cdn_url(reversed_id: &str, quality: u32) -> String {
format!("https://xiaoshenke.net/vid/{}/{}", reversed_id, quality)
}
/// Fetch the embed page using FlareSolverr to solve Cloudflare challenge,
/// then extract the signed CDN URL.
async fn get_signed_url(
&self,
video_id: &str,
quality_bitmask: u8,
requester: &mut Requester,
) -> Option<String> {
let embed_url = format!("https://xiaoshenke.net/video/{}/{}", video_id, quality_bitmask);
// Use FlareSolverr to solve the Cloudflare challenge on the embed page if FLARE_URL is set.
// This gives us the clearance cookies and user agent needed for subsequent requests.
if let Ok(flare_url) = std::env::var("FLARE_URL") {
let mut flare = Flaresolverr::new(flare_url);
if requester.proxy {
if std::env::var("BURP_URL").is_ok() {
flare.set_proxy(true);
}
}
let flare_res = flare
.solve(FlareSolverrRequest {
cmd: "request.get".to_string(),
url: embed_url.clone(),
maxTimeout: 60000,
})
.await
.ok()?;
// Store FlareSolverr cookies and user agent in the requester for subsequent requests
let useragent = flare_res.solution.userAgent;
crate::util::requester::Requester::remember_flaresolverr_user_agent(&embed_url, &useragent);
requester.user_agent = Some(useragent);
requester.store_flaresolverr_cookies(&embed_url, &flare_res.solution.cookies);
requester.client = crate::util::requester::Requester::build_client(
requester.cookie_jar.clone(),
requester.user_agent.as_deref(),
);
} else {
// Fallback: try direct request (might work if no challenge)
let _ = requester
.get_with_headers(
&embed_url,
vec![
("Referer".to_string(), "https://fullporner.com/".to_string()),
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
("Accept".to_string(), HTML_ACCEPT.to_string()),
],
None,
)
.await
.ok()?;
}
// The embed page has JavaScript that builds the video URL.
// Pattern: var id = "xxx".split('').reverse().join('');
// Then: `${prefix}/${id}/${quality}`
// We can extract the ID from the page and reverse it ourselves.
let reversed_id = Self::reverse_id(video_id);
let quality = Self::quality_bitmask_to_height(quality_bitmask);
let cdn_url = Self::build_cdn_url(&reversed_id, quality);
// Get the signed URL by requesting CDN URL WITHOUT following redirect
let upstream = requester
.get_no_redirect_raw_with_headers(
&cdn_url,
vec![("Referer".to_string(), embed_url.clone())],
)
.await
.ok()?;
if upstream.status().is_redirection() {
let location = upstream.headers().get("Location")?.to_str().ok()?;
let signed_url = if location.starts_with("//") {
format!("https:{}", location)
} else {
location.to_string()
};
// Return signed URL - serve_media will fetch it WITHOUT cookie jar (no cookies to v5.xiaoshenke.net)
Some(signed_url)
} else {
Some(cdn_url)
}
}
}
pub async fn serve_media(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").to_string();
let proxy = XiaoshenkeProxy::new();
let Some((video_id, quality_bitmask)) = XiaoshenkeProxy::parse_embed_url(&endpoint) else {
return Ok(web::HttpResponse::BadRequest().finish());
};
// HEAD: answer probes/health-checks directly without resolving the signed URL
if req.method() == Method::HEAD {
return Ok(web::HttpResponse::Ok()
.header(CONTENT_TYPE, "video/mp4")
.header(ACCEPT_RANGES, "bytes")
.finish());
}
// GET/POST: get signed URL via FlareSolverr + CDN redirect, then fetch signed URL with fresh client (no cookie jar)
let mut requester = requester.get_ref().clone();
let Some(signed_url) = proxy.get_signed_url(&video_id, quality_bitmask, &mut requester).await else {
return Ok(web::HttpResponse::BadGateway().finish());
};
// Extract Range header for seeking support
let range_header = req
.headers()
.get(RANGE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
// Fetch the signed URL with a fresh client (no cookie jar) since signed URL doesn't need cookies
let mut headers = vec![
("Referer".to_string(), format!("https://xiaoshenke.net/video/{}/{}", video_id, quality_bitmask)),
("User-Agent".to_string(), FIREFOX_USER_AGENT.to_string()),
];
if let Some(range) = &range_header {
headers.push(("Range".to_string(), range.clone()));
}
// Create a fresh client without cookie jar for the signed URL request
let client = crate::util::requester::Requester::build_client(
Arc::new(wreq::cookie::Jar::default()),
Some(FIREFOX_USER_AGENT),
);
let mut request = client.get(&signed_url);
for (key, value) in &headers {
request = request.header(key, value);
}
let upstream = match request.send().await {
Ok(resp) => resp,
Err(e) => {
crate::flow_debug!("xiaoshenke: signed URL request error: {}", e);
return Ok(web::HttpResponse::BadGateway().finish());
}
};
let status = upstream.status();
let upstream_headers = upstream.headers().clone();
let content_type = upstream_headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let mut builder = web::HttpResponse::build(status);
builder.header(CONTENT_TYPE, &content_type);
builder.header(ACCEPT_RANGES, "bytes");
if let Some(cl) = upstream_headers.get(CONTENT_LENGTH) {
if let Ok(cl_str) = cl.to_str() {
builder.header(CONTENT_LENGTH, cl_str);
}
}
if let Some(cr) = upstream_headers.get(CONTENT_RANGE) {
if let Ok(cr_str) = cr.to_str() {
builder.header(CONTENT_RANGE, cr_str);
}
}
// Stream the body using wreq's into_data_stream (yields Result<bytes::Bytes, wreq::Error>)
// Convert to ntex::util::Bytes for the response
let byte_stream = upstream.into_data_stream();
let stream = Box::pin(byte_stream.map(|chunk| {
chunk
.map(|b| Bytes::copy_from_slice(b.as_ref()))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
}));
Ok(builder.streaming(stream))
}
#[cfg(test)]
mod tests {
use super::XiaoshenkeProxy;
#[test]
fn parses_embed_url() {
assert_eq!(
XiaoshenkeProxy::parse_embed_url("video/c9266c6648b96/4"),
Some(("c9266c6648b96".to_string(), 4))
);
assert_eq!(
XiaoshenkeProxy::parse_embed_url("/video/abc123/3"),
Some(("abc123".to_string(), 3))
);
assert_eq!(XiaoshenkeProxy::parse_embed_url("other/abc/1"), None);
assert_eq!(XiaoshenkeProxy::parse_embed_url("video/onlytwo"), None);
}
#[test]
fn reverses_id() {
assert_eq!(XiaoshenkeProxy::reverse_id("c9266c6648b96"), "69b8466c6629c");
assert_eq!(XiaoshenkeProxy::reverse_id("abc123"), "321cba");
}
#[test]
fn quality_mapping() {
// Test bitmask values
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(1), 360); // 360p
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(2), 480); // 480p
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(3), 480); // 360+480 -> highest 480
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(4), 720); // 720p
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(6), 720); // 480+720 -> highest 720
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(7), 720); // 360+480+720 -> highest 720
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(8), 1080); // 1080p
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(14), 1080); // 480+720+1080 -> highest 1080
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(15), 1080); // all -> highest 1080
assert_eq!(XiaoshenkeProxy::quality_bitmask_to_height(0), 720); // default
}
#[test]
fn builds_cdn_url() {
assert_eq!(
XiaoshenkeProxy::build_cdn_url("69b8466c6629c", 720),
"https://xiaoshenke.net/vid/69b8466c6629c/720"
);
}
}

View File

@@ -189,6 +189,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::post().to(crate::proxies::melonstube::serve))
.route(web::get().to(crate::proxies::melonstube::serve)),
);
cfg.service(
web::resource("/xiaoshenke/{endpoint}*")
.route(web::post().to(crate::proxies::xiaoshenke::serve_media))
.route(web::get().to(crate::proxies::xiaoshenke::serve_media))
.route(web::head().to(crate::proxies::xiaoshenke::serve_media)),
);
}
async fn proxy2redirect(

View File

@@ -25,14 +25,14 @@ type AnyErr = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct Requester {
#[serde(skip)]
client: Client,
pub(crate) client: Client,
#[serde(skip)]
cookie_jar: Arc<Jar>,
pub(crate) cookie_jar: Arc<Jar>,
#[serde(skip)]
debug_trace_id: Option<String>,
proxy: bool,
pub(crate) debug_trace_id: Option<String>,
pub(crate) proxy: bool,
flaresolverr_session: Option<String>,
user_agent: Option<String>,
pub(crate) user_agent: Option<String>,
}
impl fmt::Debug for Requester {
@@ -75,7 +75,7 @@ impl Requester {
.map(|host| host.trim_start_matches("www.").to_ascii_lowercase())
}
fn remember_flaresolverr_user_agent(url: &str, user_agent: &str) {
pub(crate) fn remember_flaresolverr_user_agent(url: &str, user_agent: &str) {
if user_agent.is_empty() {
return;
}
@@ -155,7 +155,7 @@ impl Requester {
default.clone()
}
fn store_flaresolverr_cookies(
pub(crate) fn store_flaresolverr_cookies(
&mut self,
request_url: &str,
cookies: &[crate::util::flaresolverr::FlaresolverrCookie],
@@ -280,7 +280,7 @@ impl Requester {
.unwrap_or_else(|| "none".to_string())
}
fn build_client(cookie_jar: Arc<Jar>, user_agent: Option<&str>) -> Client {
pub(crate) fn build_client(cookie_jar: Arc<Jar>, user_agent: Option<&str>) -> Client {
let mut builder = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
@@ -427,12 +427,66 @@ impl Requester {
Ok(response)
}
/// Like [`get_raw_with_headers`], but disables redirect-following so a 3xx response's
/// `Location` header can be inspected directly.
pub async fn get_no_redirect_raw_with_headers(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Response, wreq::Error> {
self.adopt_remembered_identity(url);
let cookie_preview = self
.cookie_header_for_url(url)
.map(|cookie| crate::util::flow_debug::preview(&cookie, 160))
.unwrap_or_else(|| "none".to_string());
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_no_redirect_raw_with_headers url={} headers={} cookies={} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
self.proxy
);
let mut builder = Client::builder()
.tls_cert_verification(false)
.emulation(Emulation::Firefox151)
.cookie_provider(self.cookie_jar.clone())
.redirect(Policy::none());
if let Some(user_agent) = self.user_agent.as_deref() {
let mut hdrs = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(user_agent) {
hdrs.insert(USER_AGENT, value);
builder = builder.default_headers(hdrs);
}
}
let client = builder.build().expect("Failed to create HTTP client");
let mut request = client.get(url).version(Version::HTTP_11);
if self.proxy {
if let Ok(proxy_url) = env::var("BURP_URL") {
let proxy = Proxy::all(&proxy_url).unwrap();
request = request.proxy(proxy);
}
}
// Set custom headers
for (key, value) in headers.iter() {
request = request.header(key, value);
}
let response = request.send().await?;
self.store_response_cookies(url, &response);
Ok(response)
}
pub async fn get_raw_with_headers(
&mut self,
url: &str,
headers: Vec<(String, String)>,
) -> Result<Response, wreq::Error> {
self.get_raw_with_headers_timeout(url, headers, None).await
self.get_raw_with_headers_timeout(url, headers, None, None).await
}
pub async fn get_raw_with_headers_timeout(
@@ -440,21 +494,28 @@ impl Requester {
url: &str,
headers: Vec<(String, String)>,
timeout: Option<Duration>,
http_version: Option<Version>,
) -> Result<Response, wreq::Error> {
let cookie_preview = self.debug_cookie_preview_from_owned_headers(url, &headers);
#[cfg(not(feature = "debug"))]
let _ = &cookie_preview;
crate::flow_debug!(
"trace={} requester get_raw_with_headers url={} headers={} cookies={} proxy={}",
"trace={} requester get_raw_with_headers url={} headers={} cookies={} http_version={:?} proxy={}",
self.debug_trace_id().unwrap_or("none"),
crate::util::flow_debug::preview(url, 120),
headers.len(),
cookie_preview,
http_version,
self.proxy
);
let client = Self::build_client(self.cookie_jar.clone(), self.user_agent.as_deref());
let mut request = client.get(url).version(Version::HTTP_11);
let mut request = client.get(url);
if let Some(version) = http_version {
request = request.version(version);
} else {
request = request.version(Version::HTTP_11);
}
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}