allpornstreamd done and sxyprn updated
This commit is contained in:
5
build.rs
5
build.rs
@@ -301,6 +301,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
|||||||
module: "thaiporntv",
|
module: "thaiporntv",
|
||||||
ty: "ThaipornTvProvider",
|
ty: "ThaipornTvProvider",
|
||||||
},
|
},
|
||||||
|
ProviderDef {
|
||||||
|
id: "allpornstream",
|
||||||
|
module: "allpornstream",
|
||||||
|
ty: "AllPornStreamProvider",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
|||||||
| Provider | Group | `/api/uploaders` | Uses local `/proxy` | Notes |
|
| Provider | Group | `/api/uploaders` | Uses local `/proxy` | Notes |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `all` | `meta-search` | no | no | Aggregates all compiled providers. |
|
| `all` | `meta-search` | no | no | Aggregates all compiled providers. |
|
||||||
|
| `allpornstream` | `mainstream-tube` | no | yes | Next.js App Router scraper; extracts cards via `data-thumb-id/href/title/images` attributes; redirect proxy lazy-resolves VOE/DoodStream/StreamTape/FileMoon embeds. |
|
||||||
| `archivebate` | `live-cams` | no | no | Livewire-backed cam archive listings with platform/gender/profile shortcuts. |
|
| `archivebate` | `live-cams` | no | no | Livewire-backed cam archive listings with platform/gender/profile shortcuts. |
|
||||||
| `beeg` | `mainstream-tube` | no | no | Basic mainstream tube pattern. |
|
| `beeg` | `mainstream-tube` | no | no | Basic mainstream tube pattern. |
|
||||||
| `blowjobspro` | `mainstream-tube` | no | no | KVS-style HTML provider with async search pagination and category shortcut routing. |
|
| `blowjobspro` | `mainstream-tube` | no | no | KVS-style HTML provider with async search pagination and category shortcut routing. |
|
||||||
@@ -80,6 +81,7 @@ These resolve a provider-specific input into a `302 Location`.
|
|||||||
- `/proxy/pornhd3x/{endpoint}*`
|
- `/proxy/pornhd3x/{endpoint}*`
|
||||||
- `/proxy/shooshtime/{endpoint}*`
|
- `/proxy/shooshtime/{endpoint}*`
|
||||||
- `/proxy/pimpbunny/{endpoint}*`
|
- `/proxy/pimpbunny/{endpoint}*`
|
||||||
|
- `/proxy/allpornstream/{endpoint}*`
|
||||||
|
|
||||||
### Media/image proxies
|
### Media/image proxies
|
||||||
|
|
||||||
|
|||||||
598
src/providers/allpornstream.rs
Normal file
598
src/providers/allpornstream.rs
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
use crate::DbPool;
|
||||||
|
use crate::api::ClientVersion;
|
||||||
|
use crate::providers::{
|
||||||
|
Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme,
|
||||||
|
};
|
||||||
|
use crate::status::*;
|
||||||
|
use crate::util::cache::VideoCache;
|
||||||
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::DateTime;
|
||||||
|
use error_chain::error_chain;
|
||||||
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||||
|
use regex::Regex;
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
|
crate::providers::ProviderChannelMetadata {
|
||||||
|
group_id: "mainstream-tube",
|
||||||
|
tags: &["tube", "aggregator", "mixed"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://allpornstream.com";
|
||||||
|
const CHANNEL_ID: &str = "allpornstream";
|
||||||
|
const BROWSER_UA: &str =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Target {
|
||||||
|
Latest { page: u32 },
|
||||||
|
Search { query: String, page: u32 },
|
||||||
|
Category { slug: String, page: u32 },
|
||||||
|
Producer { slug: String, page: u32 },
|
||||||
|
Actor { slug: String, page: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AllPornStreamProvider {}
|
||||||
|
|
||||||
|
impl AllPornStreamProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||||
|
Channel {
|
||||||
|
id: CHANNEL_ID.to_string(),
|
||||||
|
name: "All Porn Stream".to_string(),
|
||||||
|
description: "Free HD porn videos aggregated from major studios and independent creators.".to_string(),
|
||||||
|
premium: false,
|
||||||
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=allpornstream.com".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
categories: vec![],
|
||||||
|
options: vec![
|
||||||
|
ChannelOption {
|
||||||
|
id: "sort".to_string(),
|
||||||
|
title: "Sort".to_string(),
|
||||||
|
description: "Browse the latest feed.".to_string(),
|
||||||
|
systemImage: "list.number".to_string(),
|
||||||
|
colorName: "blue".to_string(),
|
||||||
|
options: vec![FilterOption {
|
||||||
|
id: "new".to_string(),
|
||||||
|
title: "Latest".to_string(),
|
||||||
|
}],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
ChannelOption {
|
||||||
|
id: "sites".to_string(),
|
||||||
|
title: "Producer".to_string(),
|
||||||
|
description: "Jump directly to a studio or producer page. Use the slug from the URL (e.g. brazzers).".to_string(),
|
||||||
|
systemImage: "building.2".to_string(),
|
||||||
|
colorName: "purple".to_string(),
|
||||||
|
options: vec![FilterOption {
|
||||||
|
id: "all".to_string(),
|
||||||
|
title: "All".to_string(),
|
||||||
|
}],
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nsfw: true,
|
||||||
|
cacheDuration: Some(1800),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(target: &Target) -> String {
|
||||||
|
match target {
|
||||||
|
Target::Latest { page } => {
|
||||||
|
if *page > 1 {
|
||||||
|
format!("{BASE_URL}/?page={page}")
|
||||||
|
} else {
|
||||||
|
BASE_URL.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Search { query, page } => {
|
||||||
|
let encoded =
|
||||||
|
utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
|
||||||
|
if *page > 1 {
|
||||||
|
format!("{BASE_URL}/?search={encoded}&page={page}")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/?search={encoded}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Category { slug, page } => {
|
||||||
|
if *page > 1 {
|
||||||
|
format!("{BASE_URL}/categories/{slug}?page={page}")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/categories/{slug}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Producer { slug, page } => {
|
||||||
|
if *page > 1 {
|
||||||
|
format!("{BASE_URL}/producers/{slug}?page={page}")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/producers/{slug}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Actor { slug, page } => {
|
||||||
|
if *page > 1 {
|
||||||
|
format!("{BASE_URL}/actors/{slug}?page={page}")
|
||||||
|
} else {
|
||||||
|
format!("{BASE_URL}/actors/{slug}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_duration(text: &str) -> u32 {
|
||||||
|
let parts: Vec<u32> = text
|
||||||
|
.trim()
|
||||||
|
.split(':')
|
||||||
|
.filter_map(|p| p.parse::<u32>().ok())
|
||||||
|
.collect();
|
||||||
|
match parts.as_slice() {
|
||||||
|
[m, s] => m * 60 + s,
|
||||||
|
[h, m, s] => h * 3600 + m * 60 + s,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uploaded_at(dt: &str) -> Option<u64> {
|
||||||
|
DateTime::parse_from_rfc3339(dt)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.timestamp() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_first_image(data_images: &str) -> String {
|
||||||
|
// data_images is a JSON array, already HTML-decoded by scraper's parser.
|
||||||
|
// Find the first https:// URL in it.
|
||||||
|
if let Some(start) = data_images.find("https://") {
|
||||||
|
let rest = &data_images[start..];
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
return rest[..end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug_to_title(slug: &str) -> String {
|
||||||
|
slug.split(['-', '_'])
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(f) => format!("{}{}", f.to_uppercase(), chars.collect::<String>()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listing(&self, html: &str, options: &ServerOptions) -> Vec<VideoItem> {
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
|
||||||
|
let card_sel = match Selector::parse("[data-thumb-id][data-href][data-title][data-images]")
|
||||||
|
{
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
let time_sel = match Selector::parse("time[datetime]") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
let studio_sel = match Selector::parse("[data-ga-category='thumbnail_studio']") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
let actor_sel = match Selector::parse("[data-ga-category='thumbnail_actor']") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duration: span with class starting "absolute bottom-2" containing a time string
|
||||||
|
let dur_re = match Regex::new(
|
||||||
|
r#"<span[^>]*class="absolute[^"]*"[^>]*>(\d+:\d{2}(?::\d{2})?)</span>"#,
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Views: number directly after the eye-icon SVG closing tag
|
||||||
|
let views_re =
|
||||||
|
match Regex::new(r"</svg>\s*(\d+)\s*</div>") {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for card in document.select(&card_sel) {
|
||||||
|
let uuid = match card.value().attr("data-thumb-id") {
|
||||||
|
Some(v) if !v.is_empty() => v.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let href = match card.value().attr("data-href") {
|
||||||
|
Some(v) if v.starts_with('/') => v.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = match card.value().attr("data-title") {
|
||||||
|
Some(v) if !v.is_empty() => v.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let images_raw = card.value().attr("data-images").unwrap_or_default();
|
||||||
|
let thumb = Self::extract_first_image(images_raw);
|
||||||
|
|
||||||
|
let card_html = card.html();
|
||||||
|
|
||||||
|
// Duration from the overlay span
|
||||||
|
let duration = dur_re
|
||||||
|
.captures(&card_html)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| Self::parse_duration(m.as_str()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Views from after the eye icon SVG
|
||||||
|
let views = views_re
|
||||||
|
.captures(&card_html)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.and_then(|m| m.as_str().parse::<u32>().ok());
|
||||||
|
|
||||||
|
// video.url is the stable page URL; the proxy URL goes into formats so the
|
||||||
|
// client can supply the required Referer header alongside the stream request.
|
||||||
|
let detail_url = format!("{BASE_URL}{href}");
|
||||||
|
let proxy_target = strip_url_scheme(&detail_url);
|
||||||
|
let proxy_url = build_proxy_url(options, CHANNEL_ID, &proxy_target);
|
||||||
|
|
||||||
|
let mut item = VideoItem::new(
|
||||||
|
uuid,
|
||||||
|
title,
|
||||||
|
detail_url.clone(),
|
||||||
|
CHANNEL_ID.to_string(),
|
||||||
|
thumb,
|
||||||
|
duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !proxy_url.is_empty() {
|
||||||
|
let mut format =
|
||||||
|
VideoFormat::new(proxy_url, "auto".to_string(), "video/mp4".to_string());
|
||||||
|
format.add_http_header("Referer".to_string(), detail_url.clone());
|
||||||
|
item = item.formats(vec![format]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload date
|
||||||
|
if let Some(time_el) = card.select(&time_sel).next() {
|
||||||
|
if let Some(dt) = time_el.value().attr("datetime") {
|
||||||
|
if let Some(ts) = Self::parse_uploaded_at(dt) {
|
||||||
|
item = item.uploaded_at(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(v) = views {
|
||||||
|
item = item.views(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Producer / studio
|
||||||
|
if let Some(studio_link) = card.select(&studio_sel).next() {
|
||||||
|
let label = studio_link
|
||||||
|
.value()
|
||||||
|
.attr("aria-label")
|
||||||
|
.unwrap_or_default();
|
||||||
|
// "producer: ONLY FANS" → "ONLY FANS"
|
||||||
|
let raw_name = label
|
||||||
|
.strip_prefix("producer: ")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !raw_name.is_empty() {
|
||||||
|
let producer_href =
|
||||||
|
studio_link.value().attr("href").unwrap_or_default();
|
||||||
|
let slug = producer_href
|
||||||
|
.trim_start_matches("/producers/")
|
||||||
|
.to_string();
|
||||||
|
let display = Self::slug_to_title(&raw_name.to_lowercase().replace(' ', "-"));
|
||||||
|
item = item.uploader(display.clone());
|
||||||
|
if !slug.is_empty() {
|
||||||
|
item = item.uploader_url(format!("{BASE_URL}/producers/{slug}"));
|
||||||
|
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actors as tags – deduplicate by href, keep the longest text per actor
|
||||||
|
let mut actor_map: HashMap<String, String> = HashMap::new();
|
||||||
|
for actor_link in card.select(&actor_sel) {
|
||||||
|
let actor_href = actor_link
|
||||||
|
.value()
|
||||||
|
.attr("href")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let text = actor_link
|
||||||
|
.text()
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !actor_href.is_empty() && !text.is_empty() {
|
||||||
|
actor_map
|
||||||
|
.entry(actor_href)
|
||||||
|
.and_modify(|v| {
|
||||||
|
if text.len() > v.len() {
|
||||||
|
*v = text.clone();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut actors: Vec<String> = actor_map.into_values().collect();
|
||||||
|
actors.sort();
|
||||||
|
if !actors.is_empty() {
|
||||||
|
item = item.tags(actors);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_target(query: &str, _sort: &str, page: u32, options: &ServerOptions) -> Target {
|
||||||
|
// Explicit shortcuts: "actor:slug", "producer:slug", "category:slug"
|
||||||
|
if let Some(slug) = query.strip_prefix("actor:") {
|
||||||
|
return Target::Actor {
|
||||||
|
slug: slug.to_string(),
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(slug) = query.strip_prefix("producer:") {
|
||||||
|
return Target::Producer {
|
||||||
|
slug: slug.to_string(),
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(slug) = query.strip_prefix("category:") {
|
||||||
|
return Target::Category {
|
||||||
|
slug: slug.to_string(),
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword search
|
||||||
|
if !query.is_empty() {
|
||||||
|
return Target::Search {
|
||||||
|
query: query.to_string(),
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Producer filter from options.sites
|
||||||
|
if let Some(sites) = &options.sites {
|
||||||
|
let sites = sites.trim();
|
||||||
|
if !sites.is_empty() && sites != "all" {
|
||||||
|
return Target::Producer {
|
||||||
|
slug: sites.to_string(),
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Target::Latest { page }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_and_parse(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
target: Target,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Result<Vec<VideoItem>> {
|
||||||
|
let url = Self::build_url(&target);
|
||||||
|
|
||||||
|
if let Some((time, items)) = cache.get(&url) {
|
||||||
|
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||||
|
return Ok(items.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut requester = requester_or_default(&options, CHANNEL_ID, "fetch_and_parse");
|
||||||
|
|
||||||
|
let html = requester
|
||||||
|
.get_with_headers(
|
||||||
|
&url,
|
||||||
|
vec![
|
||||||
|
("user-agent".to_string(), BROWSER_UA.to_string()),
|
||||||
|
("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.5".to_string()),
|
||||||
|
],
|
||||||
|
Some(wreq::Version::HTTP_11),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::from(format!("request failed url={url}: {e}")))?;
|
||||||
|
|
||||||
|
if html.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = self.parse_listing(&html, &options);
|
||||||
|
|
||||||
|
if !items.is_empty() {
|
||||||
|
cache.insert(url, items.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for AllPornStreamProvider {
|
||||||
|
async fn get_videos(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
pool: DbPool,
|
||||||
|
sort: String,
|
||||||
|
query: Option<String>,
|
||||||
|
page: String,
|
||||||
|
per_page: String,
|
||||||
|
options: ServerOptions,
|
||||||
|
) -> Vec<VideoItem> {
|
||||||
|
let _ = pool;
|
||||||
|
let _ = per_page;
|
||||||
|
let page = page.parse::<u32>().unwrap_or(1);
|
||||||
|
let query_str = query.unwrap_or_default();
|
||||||
|
let target = Self::resolve_target(&query_str, &sort, page, &options);
|
||||||
|
|
||||||
|
match self.fetch_and_parse(cache, target, options).await {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(e) => {
|
||||||
|
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||||
|
Some(self.build_channel(clientversion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AllPornStreamProvider;
|
||||||
|
use crate::videos::ServerOptions;
|
||||||
|
|
||||||
|
fn make_options() -> ServerOptions {
|
||||||
|
ServerOptions {
|
||||||
|
featured: None,
|
||||||
|
category: None,
|
||||||
|
sites: None,
|
||||||
|
filter: None,
|
||||||
|
language: None,
|
||||||
|
public_url_base: Some("http://127.0.0.1:18080".to_string()),
|
||||||
|
requester: None,
|
||||||
|
network: None,
|
||||||
|
stars: None,
|
||||||
|
categories: None,
|
||||||
|
duration: None,
|
||||||
|
sort: None,
|
||||||
|
sexuality: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_latest_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Latest { page: 1 }),
|
||||||
|
"https://allpornstream.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Latest { page: 2 }),
|
||||||
|
"https://allpornstream.com/?page=2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_search_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Search {
|
||||||
|
query: "brazzers".to_string(),
|
||||||
|
page: 1
|
||||||
|
}),
|
||||||
|
"https://allpornstream.com/?search=brazzers"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Search {
|
||||||
|
query: "big tits".to_string(),
|
||||||
|
page: 2
|
||||||
|
}),
|
||||||
|
"https://allpornstream.com/?search=big%20tits&page=2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_producer_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Producer {
|
||||||
|
slug: "brazzers".to_string(),
|
||||||
|
page: 1
|
||||||
|
}),
|
||||||
|
"https://allpornstream.com/producers/brazzers"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::build_url(&super::Target::Producer {
|
||||||
|
slug: "brazzers".to_string(),
|
||||||
|
page: 2
|
||||||
|
}),
|
||||||
|
"https://allpornstream.com/producers/brazzers?page=2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_duration() {
|
||||||
|
assert_eq!(AllPornStreamProvider::parse_duration("18:42"), 1122);
|
||||||
|
assert_eq!(AllPornStreamProvider::parse_duration("1:23:45"), 5025);
|
||||||
|
assert_eq!(AllPornStreamProvider::parse_duration("00:59"), 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_first_image() {
|
||||||
|
let input =
|
||||||
|
r#"["https://example.com/thumb1.jpg","https://example.com/thumb2.jpg"]"#;
|
||||||
|
assert_eq!(
|
||||||
|
AllPornStreamProvider::extract_first_image(input),
|
||||||
|
"https://example.com/thumb1.jpg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cards_from_html() {
|
||||||
|
let provider = AllPornStreamProvider::new();
|
||||||
|
let options = make_options();
|
||||||
|
let html = r#"<!DOCTYPE html><html><body>
|
||||||
|
<div
|
||||||
|
data-thumb-id="34a7e37d-7fca-4f30-ad0b-3ab134a00f9f"
|
||||||
|
data-href="/post/34a7e37d-7fca-4f30-ad0b-3ab134a00f9f/test-video"
|
||||||
|
data-slug="/post/34a7e37d-7fca-4f30-ad0b-3ab134a00f9f/test-video"
|
||||||
|
data-title="Test Video Title"
|
||||||
|
data-images='["https://example.com/thumb.jpg"]'
|
||||||
|
>
|
||||||
|
<span class="absolute bottom-2 right-2 z-10">18:42</span>
|
||||||
|
<a data-ga-category="thumbnail_studio" aria-label="producer: ONLY FANS" href="/producers/only-fans">OF</a>
|
||||||
|
<a data-ga-category="thumbnail_actor" href="/actors/bonnie-blue">BB</a>
|
||||||
|
<a data-ga-category="thumbnail_actor" href="/actors/bonnie-blue">Bonnie Blue</a>
|
||||||
|
<time datetime="2026-05-18T19:06:53.000Z">1 hour ago</time>
|
||||||
|
<div class="flex items-center gap-1"><svg><path/><path fill-rule="evenodd" clip-rule="evenodd"></path></svg>416</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#;
|
||||||
|
|
||||||
|
let items = provider.parse_listing(html, &options);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.id, "34a7e37d-7fca-4f30-ad0b-3ab134a00f9f");
|
||||||
|
assert_eq!(item.title, "Test Video Title");
|
||||||
|
assert_eq!(item.duration, 1122);
|
||||||
|
assert_eq!(item.views, Some(416));
|
||||||
|
assert!(item.thumb.contains("example.com/thumb.jpg"));
|
||||||
|
assert_eq!(item.uploader.as_deref(), Some("Only Fans"));
|
||||||
|
assert!(item.tags.as_ref().unwrap().contains(&"Bonnie Blue".to_string()));
|
||||||
|
assert!(item.uploadedAt.is_some());
|
||||||
|
// video.url is the page URL; proxy URL is in formats[0]
|
||||||
|
assert_eq!(
|
||||||
|
item.url,
|
||||||
|
"https://allpornstream.com/post/34a7e37d-7fca-4f30-ad0b-3ab134a00f9f/test-video"
|
||||||
|
);
|
||||||
|
let format = &item.formats.as_ref().unwrap()[0];
|
||||||
|
assert!(format.url.contains("/proxy/allpornstream/"));
|
||||||
|
assert!(format.url.contains("allpornstream.com/post/34a7e37d"));
|
||||||
|
let headers = format.http_headers_pairs();
|
||||||
|
assert!(headers.iter().any(|(k, _)| k.to_lowercase() == "referer"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,13 +298,13 @@ impl SxyprnProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// take content before "<script async"
|
// take content before "<script async"
|
||||||
let blog_posts = html
|
let main_content = html
|
||||||
.split("blog_posts")
|
.split("main_content")
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.ok_or_else(|| ErrorKind::Parse("missing 'blog_posts' split point".into()))?;
|
.ok_or_else(|| ErrorKind::Parse("missing 'main_content' split point".into()))?;
|
||||||
|
|
||||||
// split into video segments (skip the first chunk)
|
// split into video segments (skip the first chunk)
|
||||||
let raw_videos: Vec<&str> = blog_posts.split("post_el_small'").skip(1).collect();
|
let raw_videos: Vec<&str> = main_content.split("post_el_small'").skip(1).collect();
|
||||||
|
|
||||||
if raw_videos.is_empty() {
|
if raw_videos.is_empty() {
|
||||||
return Err(ErrorKind::Parse("no 'post_el_small\'' segments found".into()).into());
|
return Err(ErrorKind::Parse("no 'post_el_small\'' segments found".into()).into());
|
||||||
@@ -485,19 +485,20 @@ impl SxyprnProvider {
|
|||||||
.format_note(sxyprn_url.split("/").nth(4).unwrap_or("sxyprn").to_string()),
|
.format_note(sxyprn_url.split("/").nth(4).unwrap_or("sxyprn").to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// let doodstream_urls: Vec<String> = title_links
|
let doodstream_urls: Vec<String> = title_links
|
||||||
// .iter()
|
.iter()
|
||||||
// .filter(|url| proxy_name_for_url(url).as_deref() == Some("doodstream"))
|
.filter(|url| proxy_name_for_url(url).as_deref() == Some("doodstream"))
|
||||||
// .map(|url| rewrite_hoster_url(options, url))
|
.map(|url| rewrite_hoster_url(options, url))
|
||||||
// .collect();
|
.collect();
|
||||||
|
|
||||||
// for dood_url in doodstream_urls {
|
for dood_url in doodstream_urls {
|
||||||
// formats.push(
|
formats.push(
|
||||||
// VideoFormat::m3u8(dood_url.clone(), "auto".to_string(), "m3u8".to_string())
|
VideoFormat::m3u8(dood_url.clone(), "auto".to_string(), "m3u8".to_string())
|
||||||
// .format_note("doodstream".to_string())
|
.format_note("doodstream".to_string())
|
||||||
// .format_id("doodstream".to_string()),
|
.format_id("doodstream".to_string())
|
||||||
// );
|
.http_header("Referer".to_string(), "https://sxyprn.com/".to_string()),
|
||||||
// }
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// let lulustream_urls: Vec<String> = title_links
|
// let lulustream_urls: Vec<String> = title_links
|
||||||
// .iter()
|
// .iter()
|
||||||
@@ -528,13 +529,14 @@ impl SxyprnProvider {
|
|||||||
formats.push(
|
formats.push(
|
||||||
VideoFormat::m3u8(vidara_url.clone(), "1080".to_string(), "m3u8".to_string())
|
VideoFormat::m3u8(vidara_url.clone(), "1080".to_string(), "m3u8".to_string())
|
||||||
.format_note(vidara_url.split("/").nth(4).unwrap_or("vidara").to_string())
|
.format_note(vidara_url.split("/").nth(4).unwrap_or("vidara").to_string())
|
||||||
.format_id("vidara".to_string()),
|
.format_id("vidara".to_string())
|
||||||
|
.http_header("Referer".to_string(), "https://sxyprn.com/".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut video_item = VideoItem::new(
|
let mut video_item = VideoItem::new(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
title,
|
title,
|
||||||
url.clone(),
|
format!("https://sxyprn.com/post/{}", url.clone()),
|
||||||
"sxyprn".to_string(),
|
"sxyprn".to_string(),
|
||||||
thumb,
|
thumb,
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
222
src/proxies/allpornstream.rs
Normal file
222
src/proxies/allpornstream.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ntex::web::{self, HttpRequest};
|
||||||
|
use wreq::cookie::Jar;
|
||||||
|
use wreq::redirect::Policy;
|
||||||
|
use wreq_util::Emulation;
|
||||||
|
|
||||||
|
use crate::providers::strip_url_scheme;
|
||||||
|
use crate::util::hoster_proxy::proxy_name_for_url;
|
||||||
|
use crate::util::requester::Requester;
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://allpornstream.com";
|
||||||
|
const BROWSER_UA: &str =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AllPornStreamProxy {}
|
||||||
|
|
||||||
|
impl AllPornStreamProxy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_chrome_client() -> Option<wreq::Client> {
|
||||||
|
let jar = Arc::new(Jar::default());
|
||||||
|
wreq::Client::builder()
|
||||||
|
.cert_verification(false)
|
||||||
|
.emulation(Emulation::Chrome120)
|
||||||
|
.cookie_provider(jar)
|
||||||
|
.redirect(Policy::default())
|
||||||
|
.build()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_detail_url(endpoint: &str) -> Option<String> {
|
||||||
|
let value = endpoint.trim().trim_start_matches('/');
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail_url = if value.starts_with("http://") || value.starts_with("https://") {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{value}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let detail_url = detail_url.replacen("http://", "https://", 1);
|
||||||
|
let parsed = url::Url::parse(&detail_url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
if !(host == "allpornstream.com" || host == "www.allpornstream.com") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !parsed.path().starts_with("/post/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(detail_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_headers() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"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.5".to_string()),
|
||||||
|
("user-agent".to_string(), BROWSER_UA.to_string()),
|
||||||
|
("referer".to_string(), BASE_URL.to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank streaming hosts: lower = preferred
|
||||||
|
fn host_rank(url: &str) -> u8 {
|
||||||
|
if url.contains("voe.sx") {
|
||||||
|
0
|
||||||
|
} else if url.contains("dood") {
|
||||||
|
1
|
||||||
|
} else if url.contains("streamtape") {
|
||||||
|
2
|
||||||
|
} else if url.contains("filemoon") || url.contains("moonplayer") {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_stream_url(html: &str) -> Option<String> {
|
||||||
|
// RSC data uses \" for quotes. Format inside script tags:
|
||||||
|
// \"video_urls\":{\"link\":[[\"PROVIDER\",\"https://...\"],...]
|
||||||
|
let needle = r#"\"video_urls\":{\"link\":["#;
|
||||||
|
let pos = html.find(needle)?;
|
||||||
|
let after = &html[pos + needle.len()..];
|
||||||
|
|
||||||
|
// If the link array is empty ([]) there are no known hosting entries.
|
||||||
|
// Do not scan further — the iframe section that follows contains embed
|
||||||
|
// URLs for unknown providers (e.g. mydaddy.cc) that we cannot use.
|
||||||
|
if after.starts_with(']') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture up to the closing ]] of the link array (max 2000 chars)
|
||||||
|
let array_end = after.find("]]").unwrap_or(after.len().min(2000));
|
||||||
|
let array_str = &after[..array_end];
|
||||||
|
|
||||||
|
// Extract all https:// URLs from within the array slice
|
||||||
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
|
let mut remaining = array_str;
|
||||||
|
while let Some(start) = remaining.find("https://") {
|
||||||
|
let url_str = &remaining[start..];
|
||||||
|
let end = url_str
|
||||||
|
.find("\\\"")
|
||||||
|
.or_else(|| url_str.find('"'))
|
||||||
|
.unwrap_or(url_str.len().min(300));
|
||||||
|
let url = &url_str[..end];
|
||||||
|
if !url.is_empty() {
|
||||||
|
candidates.push(url.to_string());
|
||||||
|
}
|
||||||
|
if end + 1 >= url_str.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining = &remaining[start + end + 1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.into_iter().min_by_key(|u| Self::host_rank(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::proxies::Proxy for AllPornStreamProxy {
|
||||||
|
async fn get_video_url(&self, url: String, _requester: web::types::State<Requester>) -> String {
|
||||||
|
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(client) = Self::build_chrome_client() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut request = client.get(&detail_url);
|
||||||
|
for (key, value) in Self::request_headers() {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
let Ok(response) = request.send().await else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let html = response.text().await.unwrap_or_default();
|
||||||
|
if html.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::extract_stream_url(&html).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route handler for `/proxy/allpornstream/{endpoint}*`.
|
||||||
|
///
|
||||||
|
/// Fetches the allpornstream detail page, extracts the embedded hoster URL, rewrites
|
||||||
|
/// it to the corresponding local proxy URL (e.g. `/proxy/doodstream/…`), and returns
|
||||||
|
/// a 302 redirect. This lets the client resolve the final stream URL through the correct
|
||||||
|
/// per-hoster proxy rather than hitting the raw hoster URL directly.
|
||||||
|
pub async fn serve(
|
||||||
|
req: HttpRequest,
|
||||||
|
requester: web::types::State<Requester>,
|
||||||
|
) -> Result<impl web::Responder, web::Error> {
|
||||||
|
let endpoint = req.match_info().query("endpoint").to_string();
|
||||||
|
let Some(detail_url) = AllPornStreamProxy::normalize_detail_url(&endpoint) else {
|
||||||
|
return Ok(web::HttpResponse::BadRequest().finish());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(client) = AllPornStreamProxy::build_chrome_client() else {
|
||||||
|
return Ok(web::HttpResponse::InternalServerError().finish());
|
||||||
|
};
|
||||||
|
let mut request = client.get(&detail_url);
|
||||||
|
for (key, value) in AllPornStreamProxy::request_headers() {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
let response = match request.send().await {
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
_ => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||||
|
};
|
||||||
|
let html = match response.text().await {
|
||||||
|
Ok(h) if !h.is_empty() => h,
|
||||||
|
_ => return Ok(web::HttpResponse::BadGateway().finish()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(hoster_url) = AllPornStreamProxy::extract_stream_url(&html) else {
|
||||||
|
return Ok(web::HttpResponse::BadGateway().finish());
|
||||||
|
};
|
||||||
|
|
||||||
|
// For doodstream URLs call the proxy's Chrome extraction directly so
|
||||||
|
// the /d/ → /e/ normalisation and Cloudflare bypass happen server-side.
|
||||||
|
if proxy_name_for_url(&hoster_url) == Some("doodstream") {
|
||||||
|
let embed_url = crate::proxies::doodstream::DoodstreamProxy::normalize_embed_url(&hoster_url)
|
||||||
|
.unwrap_or(hoster_url.clone());
|
||||||
|
let Some(cdn_url) = crate::proxies::doodstream::DoodstreamProxy::try_chrome_extraction(&embed_url).await else {
|
||||||
|
return Ok(web::HttpResponse::BadGateway().finish());
|
||||||
|
};
|
||||||
|
return Ok(web::HttpResponse::Found()
|
||||||
|
.header("Location", cdn_url)
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other known hosters redirect to the corresponding local proxy.
|
||||||
|
let redirect_url = match proxy_name_for_url(&hoster_url) {
|
||||||
|
Some(proxy_name) => {
|
||||||
|
let ci = req.connection_info();
|
||||||
|
format!(
|
||||||
|
"{}://{}/proxy/{}/{}",
|
||||||
|
ci.scheme(),
|
||||||
|
ci.host(),
|
||||||
|
proxy_name,
|
||||||
|
strip_url_scheme(&hoster_url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => hoster_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(web::HttpResponse::Found()
|
||||||
|
.header("Location", redirect_url)
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ntex::web;
|
use ntex::web;
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
use wreq::cookie::Jar;
|
||||||
|
use wreq::redirect::Policy;
|
||||||
|
use wreq_util::Emulation;
|
||||||
|
|
||||||
use crate::util::requester::Requester;
|
use crate::util::requester::Requester;
|
||||||
|
|
||||||
@@ -12,14 +17,39 @@ impl DoodstreamProxy {
|
|||||||
Self {}
|
Self {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert any doodstream URL variant to the embed-player URL (`/e/{id}`).
|
||||||
|
/// Handles `/d/` (download page) and pass-through for already-correct `/e/` paths.
|
||||||
|
/// Returns `None` only if the host is not an allowed doodstream host.
|
||||||
|
pub(crate) fn normalize_embed_url(url: &str) -> Option<String> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
if !Self::is_allowed_host(host) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = parsed.path();
|
||||||
|
// Replace /d/ with /e/; everything else stays as-is
|
||||||
|
let new_path = if let Some(id) = path.strip_prefix("/d/") {
|
||||||
|
format!("/e/{id}")
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
};
|
||||||
|
Some(format!("https://{host}{new_path}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_detail_url(endpoint: &str) -> Option<String> {
|
fn normalize_detail_url(endpoint: &str) -> Option<String> {
|
||||||
let normalized = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
let url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||||
endpoint.trim().to_string()
|
endpoint.trim().to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("https://{}", endpoint.trim_start_matches('/'))
|
format!("https://{}", endpoint.trim_start_matches('/'))
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::is_allowed_detail_url(&normalized).then_some(normalized)
|
// is_allowed_detail_url checks host + path prefix (/t/, /e/, /d/)
|
||||||
|
if !Self::is_allowed_detail_url(&url) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise /d/ (download page) to /e/ (embed player)
|
||||||
|
Some(Self::normalize_embed_url(&url).unwrap_or(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_allowed_host(host: &str) -> bool {
|
fn is_allowed_host(host: &str) -> bool {
|
||||||
@@ -31,6 +61,8 @@ impl DoodstreamProxy {
|
|||||||
| "www.trailerhg.xyz"
|
| "www.trailerhg.xyz"
|
||||||
| "streamhg.com"
|
| "streamhg.com"
|
||||||
| "www.streamhg.com"
|
| "www.streamhg.com"
|
||||||
|
| "doodstream.com"
|
||||||
|
| "www.doodstream.com"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +274,22 @@ impl DoodstreamProxy {
|
|||||||
Some(format!("{origin}{relative}"))
|
Some(format!("{origin}{relative}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn random_alphanumeric(len: usize) -> String {
|
||||||
|
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let mut seed = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.subsec_nanos() as u64;
|
||||||
|
(0..len)
|
||||||
|
.map(|_| {
|
||||||
|
seed = seed
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
chars[(seed >> 33) as usize % chars.len()] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn compose_pass_md5_media_url(pass_md5_url: &str, response_body: &str) -> Option<String> {
|
fn compose_pass_md5_media_url(pass_md5_url: &str, response_body: &str) -> Option<String> {
|
||||||
let raw = response_body
|
let raw = response_body
|
||||||
.trim()
|
.trim()
|
||||||
@@ -262,32 +310,45 @@ impl DoodstreamProxy {
|
|||||||
format!("{}://{}{}", parsed.scheme(), host, raw)
|
format!("{}://{}{}", parsed.scheme(), host, raw)
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = Url::parse(pass_md5_url)
|
let parsed = Url::parse(pass_md5_url).ok()?;
|
||||||
.ok()
|
|
||||||
.and_then(|url| url.query().map(str::to_string));
|
if let Some(query) = parsed.query().map(str::to_string) {
|
||||||
if let Some(query) = query {
|
// Old format: token and expiry are in the pass_md5 query string
|
||||||
if !query.is_empty() && !media_url.contains("token=") {
|
if !query.is_empty() && !media_url.contains("token=") {
|
||||||
let separator = if media_url.contains('?') { '&' } else { '?' };
|
let separator = if media_url.contains('?') { '&' } else { '?' };
|
||||||
media_url.push(separator);
|
media_url.push(separator);
|
||||||
media_url.push_str(&query);
|
media_url.push_str(&query);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// New doodstream format: token is the last path segment, base URL needs
|
||||||
|
// a random 10-char suffix and expiry appended (mirrors makePlay() in the player JS)
|
||||||
|
let token = parsed.path_segments()?.last()?.to_string();
|
||||||
|
if !token.is_empty() {
|
||||||
|
let rand = Self::random_alphanumeric(10);
|
||||||
|
let expiry = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
media_url.push_str(&rand);
|
||||||
|
media_url.push_str(&format!("?token={token}&expiry={expiry}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Self::sanitize_media_url(&media_url))
|
Some(Self::sanitize_media_url(&media_url))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_stream_from_pass_md5(
|
async fn resolve_stream_from_pass_md5(
|
||||||
detail_url: &str,
|
page_url: &str,
|
||||||
html: &str,
|
html: &str,
|
||||||
requester: &mut Requester,
|
requester: &mut Requester,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let pass_md5_url = Self::extract_pass_md5_url(html, detail_url).or_else(|| {
|
let pass_md5_url = Self::extract_pass_md5_url(html, page_url).or_else(|| {
|
||||||
Self::unpack_packer(html)
|
Self::unpack_packer(html)
|
||||||
.and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, detail_url))
|
.and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, page_url))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let headers = vec![
|
let headers = vec![
|
||||||
("Referer".to_string(), detail_url.to_string()),
|
("Referer".to_string(), page_url.to_string()),
|
||||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||||
("Accept".to_string(), "*/*".to_string()),
|
("Accept".to_string(), "*/*".to_string()),
|
||||||
];
|
];
|
||||||
@@ -299,13 +360,87 @@ impl DoodstreamProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DoodstreamProxy {
|
||||||
|
// Cloudflare on playmogo.com (where doodstream.com redirects) requires a Chrome TLS
|
||||||
|
// fingerprint. Firefox136 (the default Requester emulation) gets 403. Chrome120 bypasses it.
|
||||||
|
fn build_chrome_client() -> Option<wreq::Client> {
|
||||||
|
let jar = Arc::new(Jar::default());
|
||||||
|
wreq::Client::builder()
|
||||||
|
.cert_verification(false)
|
||||||
|
.emulation(Emulation::Chrome120)
|
||||||
|
.cookie_provider(jar)
|
||||||
|
.redirect(Policy::default())
|
||||||
|
.build()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn try_chrome_extraction(detail_url: &str) -> Option<String> {
|
||||||
|
let client = Self::build_chrome_client()?;
|
||||||
|
|
||||||
|
// No version override — let Chrome120 emulation negotiate HTTP/2 via ALPN,
|
||||||
|
// which Cloudflare requires for Chrome fingerprints (HTTP/1.1 gets 403)
|
||||||
|
let mut request = client.get(detail_url);
|
||||||
|
for (key, value) in Self::request_headers(detail_url) {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
let response = request.send().await.ok()?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let effective_url = response.url().to_string();
|
||||||
|
let html = response.text().await.ok()?;
|
||||||
|
|
||||||
|
if let Some(url) = Self::extract_stream_url(&html) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass_md5_url = Self::extract_pass_md5_url(&html, &effective_url).or_else(|| {
|
||||||
|
Self::unpack_packer(&html)
|
||||||
|
.and_then(|unpacked| Self::extract_pass_md5_url(&unpacked, &effective_url))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pm_response = client
|
||||||
|
.get(&pass_md5_url)
|
||||||
|
.header("Referer", &effective_url)
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !pm_response.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pm_body = pm_response.text().await.ok()?;
|
||||||
|
Self::compose_pass_md5_media_url(&pass_md5_url, &pm_body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl crate::proxies::Proxy for DoodstreamProxy {
|
impl crate::proxies::Proxy for DoodstreamProxy {
|
||||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||||
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
let Some(detail_url) = Self::normalize_detail_url(&url) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chrome120 emulation bypasses Cloudflare on playmogo.com (doodstream's redirect target)
|
||||||
|
if let Some(cdn_url) = Self::try_chrome_extraction(&detail_url).await {
|
||||||
|
return cdn_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: standard Requester (Firefox136 + FlareSolverr)
|
||||||
let mut requester = requester.get_ref().clone();
|
let mut requester = requester.get_ref().clone();
|
||||||
|
let (html, effective_url) = match requester
|
||||||
|
.get_raw_with_headers(&detail_url, Self::request_headers(&detail_url))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
let effective_url = response.url().to_string();
|
||||||
|
match response.text().await {
|
||||||
|
Ok(text) => (text, effective_url),
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
let html = match requester
|
let html = match requester
|
||||||
.get_with_headers(&detail_url, Self::request_headers(&detail_url), None)
|
.get_with_headers(&detail_url, Self::request_headers(&detail_url), None)
|
||||||
.await
|
.await
|
||||||
@@ -313,13 +448,16 @@ impl crate::proxies::Proxy for DoodstreamProxy {
|
|||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(_) => return String::new(),
|
Err(_) => return String::new(),
|
||||||
};
|
};
|
||||||
|
(html, detail_url.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(url) = Self::extract_stream_url(&html) {
|
if let Some(url) = Self::extract_stream_url(&html) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) =
|
if let Some(url) =
|
||||||
Self::resolve_stream_from_pass_md5(&detail_url, &html, &mut requester).await
|
Self::resolve_stream_from_pass_md5(&effective_url, &html, &mut requester).await
|
||||||
{
|
{
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -340,6 +478,9 @@ mod tests {
|
|||||||
assert!(DoodstreamProxy::is_allowed_detail_url(
|
assert!(DoodstreamProxy::is_allowed_detail_url(
|
||||||
"https://trailerhg.xyz/e/ttdc7a6qpskt"
|
"https://trailerhg.xyz/e/ttdc7a6qpskt"
|
||||||
));
|
));
|
||||||
|
assert!(DoodstreamProxy::is_allowed_detail_url(
|
||||||
|
"https://doodstream.com/e/31xp1rqt975g"
|
||||||
|
));
|
||||||
assert!(!DoodstreamProxy::is_allowed_detail_url(
|
assert!(!DoodstreamProxy::is_allowed_detail_url(
|
||||||
"http://turboplayers.xyz/t/69bdfb21cc640"
|
"http://turboplayers.xyz/t/69bdfb21cc640"
|
||||||
));
|
));
|
||||||
@@ -390,6 +531,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn composes_media_url_from_pass_md5_response_new_format() {
|
||||||
|
// New doodstream format: token in path, no query string, base URL needs
|
||||||
|
// random suffix + ?token=TOKEN&expiry=TIMESTAMP appended
|
||||||
|
let pass_md5_url =
|
||||||
|
"https://playmogo.com/pass_md5/263443276-hash/wyr3joknzwbzdhufty55banc";
|
||||||
|
let body =
|
||||||
|
"https://mx273o.cloudatacdn.com/u5kj6mn5xpa3sdgge7d24z/vgz4woi6uq~";
|
||||||
|
let result = DoodstreamProxy::compose_pass_md5_media_url(pass_md5_url, body)
|
||||||
|
.expect("should produce a URL");
|
||||||
|
assert!(result.starts_with(body), "must start with base URL");
|
||||||
|
assert!(
|
||||||
|
result.contains("?token=wyr3joknzwbzdhufty55banc&expiry="),
|
||||||
|
"must contain token and expiry"
|
||||||
|
);
|
||||||
|
// random suffix is 10 chars between base URL and '?'
|
||||||
|
let suffix_start = body.len();
|
||||||
|
let query_start = result.find('?').unwrap();
|
||||||
|
assert_eq!(query_start - suffix_start, 10, "random suffix must be 10 chars");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_relative_pass_md5_url() {
|
fn extracts_relative_pass_md5_url() {
|
||||||
let html = r#"
|
let html = r#"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::proxies::allpornstream::AllPornStreamProxy;
|
||||||
use crate::proxies::archivebate::ArchivebateProxy;
|
use crate::proxies::archivebate::ArchivebateProxy;
|
||||||
use crate::proxies::clapdat::ClapdatProxy;
|
use crate::proxies::clapdat::ClapdatProxy;
|
||||||
use crate::proxies::doodstream::DoodstreamProxy;
|
use crate::proxies::doodstream::DoodstreamProxy;
|
||||||
@@ -16,6 +17,7 @@ use crate::proxies::vidara::VidaraProxy;
|
|||||||
use crate::proxies::lulustream::LulustreamProxy;
|
use crate::proxies::lulustream::LulustreamProxy;
|
||||||
use crate::proxies::thaiporntv::ThaipornTvProxy;
|
use crate::proxies::thaiporntv::ThaipornTvProxy;
|
||||||
|
|
||||||
|
pub mod allpornstream;
|
||||||
pub mod archivebate;
|
pub mod archivebate;
|
||||||
pub mod clapdat;
|
pub mod clapdat;
|
||||||
pub mod doodstream;
|
pub mod doodstream;
|
||||||
@@ -40,6 +42,7 @@ pub mod vjav;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AnyProxy {
|
pub enum AnyProxy {
|
||||||
|
AllPornStream(AllPornStreamProxy),
|
||||||
Archivebate(ArchivebateProxy),
|
Archivebate(ArchivebateProxy),
|
||||||
Doodstream(DoodstreamProxy),
|
Doodstream(DoodstreamProxy),
|
||||||
Sxyprn(SxyprnProxy),
|
Sxyprn(SxyprnProxy),
|
||||||
@@ -65,6 +68,7 @@ pub trait Proxy {
|
|||||||
impl Proxy for AnyProxy {
|
impl Proxy for AnyProxy {
|
||||||
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
async fn get_video_url(&self, url: String, requester: web::types::State<Requester>) -> String {
|
||||||
match self {
|
match self {
|
||||||
|
AnyProxy::AllPornStream(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Archivebate(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Archivebate(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Doodstream(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Doodstream(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Sxyprn(p) => p.get_video_url(url, requester).await,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use ntex::web::{self, HttpRequest};
|
use ntex::web::{self, HttpRequest};
|
||||||
|
|
||||||
|
use crate::proxies::allpornstream::AllPornStreamProxy;
|
||||||
use crate::proxies::archivebate::ArchivebateProxy;
|
use crate::proxies::archivebate::ArchivebateProxy;
|
||||||
use crate::proxies::clapdat::ClapdatProxy;
|
use crate::proxies::clapdat::ClapdatProxy;
|
||||||
use crate::proxies::doodstream::DoodstreamProxy;
|
use crate::proxies::doodstream::DoodstreamProxy;
|
||||||
@@ -135,6 +136,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(proxy2redirect))
|
.route(web::post().to(proxy2redirect))
|
||||||
.route(web::get().to(proxy2redirect)),
|
.route(web::get().to(proxy2redirect)),
|
||||||
);
|
);
|
||||||
|
cfg.service(
|
||||||
|
web::resource("/allpornstream/{endpoint}*")
|
||||||
|
.route(web::post().to(crate::proxies::allpornstream::serve))
|
||||||
|
.route(web::get().to(crate::proxies::allpornstream::serve)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy2redirect(
|
async fn proxy2redirect(
|
||||||
@@ -170,6 +176,7 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
|||||||
"spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())),
|
"spankbang" => Some(AnyProxy::Spankbang(SpankbangProxy::new())),
|
||||||
"lulustream" => Some(AnyProxy::Lulustream(LulustreamProxy::new())),
|
"lulustream" => Some(AnyProxy::Lulustream(LulustreamProxy::new())),
|
||||||
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
||||||
|
"allpornstream" => Some(AnyProxy::AllPornStream(AllPornStreamProxy::new())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user