tube8
This commit is contained in:
5
build.rs
5
build.rs
@@ -306,6 +306,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
|||||||
module: "allpornstream",
|
module: "allpornstream",
|
||||||
ty: "AllPornStreamProvider",
|
ty: "AllPornStreamProvider",
|
||||||
},
|
},
|
||||||
|
ProviderDef {
|
||||||
|
id: "tube8",
|
||||||
|
module: "tube8",
|
||||||
|
ty: "Tube8Provider",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
|||||||
| `yesporn` | `mainstream-tube` | no | no | Preview format examples. |
|
| `yesporn` | `mainstream-tube` | no | no | Preview format examples. |
|
||||||
| `youjizz` | `mainstream-tube` | no | no | Mainstream tube provider. |
|
| `youjizz` | `mainstream-tube` | no | no | Mainstream tube provider. |
|
||||||
| `youporn` | `mainstream-tube` | no | no | Pornhub-network HTML provider with watch-page playback URLs and tag/channel/pornstar shortcuts. |
|
| `youporn` | `mainstream-tube` | no | no | Pornhub-network HTML provider with watch-page playback URLs and tag/channel/pornstar shortcuts. |
|
||||||
|
| `tube8` | `mainstream-tube` | no | yes | Aylo/MindGeek platform scraper; redirect proxy fetches signed `/media/hls/?s=TOKEN` endpoint and returns highest-quality CDN HLS URL; supports tag/category/channel/pornstar shortcut queries. |
|
||||||
|
|
||||||
## Proxy Routes
|
## Proxy Routes
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ These resolve a provider-specific input into a `302 Location`.
|
|||||||
- `/proxy/shooshtime/{endpoint}*`
|
- `/proxy/shooshtime/{endpoint}*`
|
||||||
- `/proxy/pimpbunny/{endpoint}*`
|
- `/proxy/pimpbunny/{endpoint}*`
|
||||||
- `/proxy/allpornstream/{endpoint}*`
|
- `/proxy/allpornstream/{endpoint}*`
|
||||||
|
- `/proxy/tube8/{endpoint}*`
|
||||||
|
|
||||||
### Media/image proxies
|
### Media/image proxies
|
||||||
|
|
||||||
|
|||||||
601
src/providers/tube8.rs
Normal file
601
src/providers/tube8.rs
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
use crate::DbPool;
|
||||||
|
use crate::api::ClientVersion;
|
||||||
|
use crate::providers::{Provider, build_proxy_url, report_provider_error, requester_or_default};
|
||||||
|
use crate::status::*;
|
||||||
|
use crate::util::cache::VideoCache;
|
||||||
|
use crate::util::parse_abbreviated_number;
|
||||||
|
use crate::util::time::parse_time_to_seconds;
|
||||||
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||||
|
use scraper::{ElementRef, Html, Selector};
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||||
|
crate::providers::ProviderChannelMetadata {
|
||||||
|
group_id: "mainstream-tube",
|
||||||
|
tags: &["mainstream", "studio", "search"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://www.tube8.com";
|
||||||
|
const CHANNEL_ID: &str = "tube8";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tube8Provider {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Target {
|
||||||
|
Latest,
|
||||||
|
MostViewed,
|
||||||
|
TopRated,
|
||||||
|
Search { query: String },
|
||||||
|
Tag { slug: String },
|
||||||
|
Category { slug: String },
|
||||||
|
Channel { slug: String },
|
||||||
|
Pornstar { slug: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tube8Provider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
url: BASE_URL.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_channel(&self, _cv: ClientVersion) -> Channel {
|
||||||
|
Channel {
|
||||||
|
id: CHANNEL_ID.to_string(),
|
||||||
|
name: "Tube8".to_string(),
|
||||||
|
description:
|
||||||
|
"Tube8 mainstream tube with latest, most-viewed, top-rated, search, and tag/channel/pornstar shortcuts. Playback uses a signed HLS proxy."
|
||||||
|
.to_string(),
|
||||||
|
premium: false,
|
||||||
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=tube8.com".to_string(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
categories: vec![],
|
||||||
|
options: vec![ChannelOption {
|
||||||
|
id: "sort".to_string(),
|
||||||
|
title: "Sort".to_string(),
|
||||||
|
description: "Browse Tube8 by sort order.".to_string(),
|
||||||
|
systemImage: "list.number".to_string(),
|
||||||
|
colorName: "blue".to_string(),
|
||||||
|
options: vec![
|
||||||
|
FilterOption {
|
||||||
|
id: "new".to_string(),
|
||||||
|
title: "Newest".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "popular".to_string(),
|
||||||
|
title: "Most Viewed".to_string(),
|
||||||
|
},
|
||||||
|
FilterOption {
|
||||||
|
id: "rated".to_string(),
|
||||||
|
title: "Top Rated".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiSelect: false,
|
||||||
|
}],
|
||||||
|
nsfw: true,
|
||||||
|
cacheDuration: Some(1800),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selector(value: &str) -> Option<Selector> {
|
||||||
|
Selector::parse(value).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_text(value: &str) -> String {
|
||||||
|
decode(value.as_bytes())
|
||||||
|
.to_string()
|
||||||
|
.unwrap_or_else(|_| value.to_string())
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_url(&self, value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
if trimmed.starts_with("//") {
|
||||||
|
return format!("https:{trimmed}");
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.url.trim_end_matches('/'),
|
||||||
|
trimmed.trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_headers() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"User-Agent".to_string(),
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Accept".to_string(),
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
("Accept-Language".to_string(), "en-US,en;q=0.5".to_string()),
|
||||||
|
("Referer".to_string(), format!("{BASE_URL}/")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_from_request(query: Option<&str>, sort: &str) -> Target {
|
||||||
|
if let Some(q) = query.map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
let lower = q.to_ascii_lowercase();
|
||||||
|
|
||||||
|
for (prefix, kind) in [
|
||||||
|
("tag:", "tag"),
|
||||||
|
("category:", "category"),
|
||||||
|
("cat:", "category"),
|
||||||
|
("channel:", "channel"),
|
||||||
|
("pornstar:", "pornstar"),
|
||||||
|
("model:", "pornstar"),
|
||||||
|
] {
|
||||||
|
if let Some(rest) = lower.strip_prefix(prefix) {
|
||||||
|
let slug = rest.trim().replace(' ', "-");
|
||||||
|
if !slug.is_empty() {
|
||||||
|
return match kind {
|
||||||
|
"tag" => Target::Tag { slug },
|
||||||
|
"category" => Target::Category { slug },
|
||||||
|
"channel" => Target::Channel { slug },
|
||||||
|
_ => Target::Pornstar { slug },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Target::Search { query: q.to_string() };
|
||||||
|
}
|
||||||
|
|
||||||
|
match sort.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"popular" | "viewed" | "most_viewed" | "mv" => Target::MostViewed,
|
||||||
|
"rated" | "top" | "top_rated" | "tr" => Target::TopRated,
|
||||||
|
_ => Target::Latest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(&self, target: &Target, page: u16) -> String {
|
||||||
|
match target {
|
||||||
|
Target::Latest => {
|
||||||
|
// Page 1 is the home page; page 2+ use /newest/page/N/
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/newest/page/{page}/", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::MostViewed => {
|
||||||
|
// Page 1: /mostviewed.html; page 2+: /most-viewed/page/N/
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/most-viewed/page/{page}/", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/mostviewed.html/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::TopRated => {
|
||||||
|
// Page 1: /top.html; page 2+: /top/page/N/
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/top/page/{page}/", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/top.html/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Search { query } => {
|
||||||
|
let encoded: String =
|
||||||
|
form_urlencoded::byte_serialize(query.as_bytes()).collect();
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/searches.html/?q={encoded}&page={page}", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/searches.html/?q={encoded}", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Tag { slug } => {
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/porntags/{slug}/?page={page}", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/porntags/{slug}/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Category { slug } => {
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/cat/{slug}/?page={page}", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/cat/{slug}/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Channel { slug } => {
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/channel/{slug}/?page={page}", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/channel/{slug}/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Target::Pornstar { slug } => {
|
||||||
|
if page > 1 {
|
||||||
|
format!("{}/pornstar/{slug}/?page={page}", self.url)
|
||||||
|
} else {
|
||||||
|
format!("{}/pornstar/{slug}/", self.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(node: Option<ElementRef<'_>>) -> String {
|
||||||
|
node.map(|v| Self::normalize_text(&v.text().collect::<String>()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_items(&self, html: &str, options: &ServerOptions) -> Vec<VideoItem> {
|
||||||
|
let document = Html::parse_document(html);
|
||||||
|
|
||||||
|
let Some(card_sel) = Self::selector("article.video-box.js_video-box") else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let link_sel = Self::selector("a[data-testid='plw_video_thumbnail_link']");
|
||||||
|
let title_sel = Self::selector("a.video-title-text span");
|
||||||
|
let thumb_sel = Self::selector("img.thumb-image");
|
||||||
|
let duration_sel = Self::selector(".tm_video_duration span");
|
||||||
|
let views_sel = Self::selector("span.info-views");
|
||||||
|
let uploader_sel = Self::selector("a.author-title-text");
|
||||||
|
let performer_sel = Self::selector("a.channel-performer");
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
for card in document.select(&card_sel) {
|
||||||
|
let id = card
|
||||||
|
.value()
|
||||||
|
.attr("data-video-id")
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if id.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title - prefer aria-label on article; fall back to title link span
|
||||||
|
let title = card
|
||||||
|
.value()
|
||||||
|
.attr("aria-label")
|
||||||
|
.map(Self::normalize_text)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
title_sel
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| card.select(s).next())
|
||||||
|
.map(|v| Self::normalize_text(&v.text().collect::<String>()))
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if title.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
let thumb = thumb_sel
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| card.select(s).next())
|
||||||
|
.and_then(|v| {
|
||||||
|
v.value()
|
||||||
|
.attr("data-src")
|
||||||
|
.or_else(|| v.value().attr("data-poster"))
|
||||||
|
.or_else(|| v.value().attr("src"))
|
||||||
|
})
|
||||||
|
.map(|v| self.normalize_url(v))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Preview flipbook clip from the thumbnail link's data-mediabook
|
||||||
|
let preview = link_sel
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| card.select(s).next())
|
||||||
|
.and_then(|v| v.value().attr("data-mediabook"))
|
||||||
|
.map(|v| v.replace("&", "&"))
|
||||||
|
.filter(|v| !v.is_empty() && !v.starts_with("data:"));
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
let duration_text =
|
||||||
|
Self::text_of(duration_sel.as_ref().and_then(|s| card.select(s).next()));
|
||||||
|
let duration = parse_time_to_seconds(&duration_text).unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
// Views (first span.info-views) and rating (second span.info-views, contains "XX%")
|
||||||
|
let all_views: Vec<_> = views_sel
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| card.select(s).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let views = all_views
|
||||||
|
.first()
|
||||||
|
.map(|v| Self::normalize_text(&v.text().collect::<String>()))
|
||||||
|
.and_then(|t| parse_abbreviated_number(&t))
|
||||||
|
.map(|v| v as u32);
|
||||||
|
let rating = all_views
|
||||||
|
.get(1)
|
||||||
|
.map(|v| {
|
||||||
|
Self::normalize_text(&v.text().collect::<String>()).replace('%', "")
|
||||||
|
})
|
||||||
|
.and_then(|v| v.parse::<f32>().ok());
|
||||||
|
|
||||||
|
// Uploader name from article data attribute (most reliable); href from link
|
||||||
|
let uploader_name = card
|
||||||
|
.value()
|
||||||
|
.attr("data-uploader-name")
|
||||||
|
.map(Self::normalize_text)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
uploader_sel
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| card.select(s).next())
|
||||||
|
.map(|v| Self::normalize_text(&v.text().collect::<String>()))
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
});
|
||||||
|
let uploader_href = uploader_sel
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| card.select(s).next())
|
||||||
|
.and_then(|v| v.value().attr("href"))
|
||||||
|
.map(|v| self.normalize_url(v));
|
||||||
|
// Namespaced uploader ID from article attribute
|
||||||
|
let uploader_id = card
|
||||||
|
.value()
|
||||||
|
.attr("data-uploader-id")
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(|v| format!("{CHANNEL_ID}:{v}"));
|
||||||
|
|
||||||
|
// Performer tags from channel-performer links
|
||||||
|
let mut tags: Vec<String> = Vec::new();
|
||||||
|
if let Some(sel) = &performer_sel {
|
||||||
|
for p in card.select(sel) {
|
||||||
|
let t = Self::normalize_text(&p.text().collect::<String>());
|
||||||
|
if !t.is_empty()
|
||||||
|
&& !tags.iter().any(|x: &String| x.eq_ignore_ascii_case(&t))
|
||||||
|
{
|
||||||
|
tags.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy URL resolves signed HLS via our redirect proxy
|
||||||
|
let proxy_url = build_proxy_url(options, CHANNEL_ID, &id);
|
||||||
|
|
||||||
|
let mut item = VideoItem::new(
|
||||||
|
id.clone(),
|
||||||
|
title,
|
||||||
|
format!("https://www.tube8.com/porn-video/{id}/"),
|
||||||
|
CHANNEL_ID.to_string(),
|
||||||
|
thumb,
|
||||||
|
duration,
|
||||||
|
);
|
||||||
|
item.views = views;
|
||||||
|
if let Some(r) = rating {
|
||||||
|
item = item.rating(r);
|
||||||
|
}
|
||||||
|
if let Some(name) = uploader_name {
|
||||||
|
item = item.uploader(name);
|
||||||
|
}
|
||||||
|
if let Some(url) = uploader_href {
|
||||||
|
item.uploaderUrl = Some(url);
|
||||||
|
}
|
||||||
|
if let Some(uid) = uploader_id {
|
||||||
|
item.uploaderId = Some(uid);
|
||||||
|
}
|
||||||
|
if let Some(p) = preview {
|
||||||
|
item = item.preview(p);
|
||||||
|
}
|
||||||
|
if !tags.is_empty() {
|
||||||
|
item = item.tags(tags);
|
||||||
|
}
|
||||||
|
item = item.formats(vec![
|
||||||
|
VideoFormat::m3u8(proxy_url, "auto".to_string(), "tube8".to_string())
|
||||||
|
.ext("mp4".to_string())
|
||||||
|
.protocol("m3u8_native".to_string())
|
||||||
|
.video_ext("mp4".to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for Tube8Provider {
|
||||||
|
async fn get_videos(
|
||||||
|
&self,
|
||||||
|
cache: VideoCache,
|
||||||
|
_db_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 target = Self::target_from_request(query.as_deref(), &sort);
|
||||||
|
let url = self.build_url(&target, page);
|
||||||
|
|
||||||
|
let old_items = match cache.get(&url) {
|
||||||
|
Some((time, items)) if time.elapsed().unwrap_or_default().as_secs() < 300 => {
|
||||||
|
return items.clone();
|
||||||
|
}
|
||||||
|
Some((_, items)) => items.clone(),
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos");
|
||||||
|
let text = match requester
|
||||||
|
.get_with_headers(&url, Self::html_headers(), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
report_provider_error(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"get_videos.request",
|
||||||
|
&format!("url={url}; error={e}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return old_items;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = self.parse_items(&text, &options);
|
||||||
|
if items.is_empty() {
|
||||||
|
return old_items;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.remove(&url);
|
||||||
|
cache.insert(url, items.clone());
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel(&self, cv: ClientVersion) -> Option<Channel> {
|
||||||
|
Some(self.build_channel(cv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn provider() -> Tube8Provider {
|
||||||
|
Tube8Provider::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_sort_targets() {
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(None, "new"),
|
||||||
|
Target::Latest
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(None, "popular"),
|
||||||
|
Target::MostViewed
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(None, "rated"),
|
||||||
|
Target::TopRated
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_prefix_shortcuts() {
|
||||||
|
let p = provider();
|
||||||
|
let _ = p;
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(Some("tag:lesbian"), "new"),
|
||||||
|
Target::Tag { slug } if slug == "lesbian"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(Some("channel:brazzers"), "new"),
|
||||||
|
Target::Channel { slug } if slug == "brazzers"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(Some("pornstar:mia khalifa"), "new"),
|
||||||
|
Target::Pornstar { slug } if slug == "mia-khalifa"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Tube8Provider::target_from_request(Some("cat:teens"), "new"),
|
||||||
|
Target::Category { slug } if slug == "teens"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_latest_pages() {
|
||||||
|
let p = provider();
|
||||||
|
assert_eq!(p.build_url(&Target::Latest, 1), "https://www.tube8.com/");
|
||||||
|
assert_eq!(
|
||||||
|
p.build_url(&Target::Latest, 2),
|
||||||
|
"https://www.tube8.com/newest/page/2/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_search_pages() {
|
||||||
|
let p = provider();
|
||||||
|
let t = Target::Search { query: "teen creampie".to_string() };
|
||||||
|
assert_eq!(
|
||||||
|
p.build_url(&t, 1),
|
||||||
|
"https://www.tube8.com/searches.html/?q=teen+creampie"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.build_url(&t, 2),
|
||||||
|
"https://www.tube8.com/searches.html/?q=teen+creampie&page=2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_video_cards() {
|
||||||
|
let p = provider();
|
||||||
|
let html = r#"
|
||||||
|
<article class="video-box pc js_video-box js-pop"
|
||||||
|
data-video-id="12345"
|
||||||
|
data-uploader-id="999"
|
||||||
|
data-uploader-name="TestChannel"
|
||||||
|
aria-label="Test Video Title">
|
||||||
|
<a href="/porn-video/12345/" data-testid="plw_video_thumbnail_link"
|
||||||
|
data-mediabook="https://ev-ph.t8cdn.com/videos/test_fb.mp4?validfrom=1&validto=2">
|
||||||
|
<div class="thumb-image-container">
|
||||||
|
<img class="thumb-image js_lazy"
|
||||||
|
data-src="https://ei-ph.t8cdn.com/videos/test/thumb.jpg"
|
||||||
|
src="data:image/png;base64,xxx"/>
|
||||||
|
</div>
|
||||||
|
<div class="video-properties">
|
||||||
|
<div class="video-duration tm_video_duration"><span>08:30</span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="thumb-info-wrapper">
|
||||||
|
<a class="author-title-text" href="/channel/testchannel/">TestChannel</a>
|
||||||
|
<a class="channel-performer" href="/pornstar/jane-doe/">Jane Doe</a>
|
||||||
|
<span class='info-views'>12.3K</span>
|
||||||
|
<span class='info-views'>87%</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let opts = ServerOptions {
|
||||||
|
featured: None,
|
||||||
|
category: None,
|
||||||
|
sites: None,
|
||||||
|
filter: None,
|
||||||
|
language: None,
|
||||||
|
public_url_base: None,
|
||||||
|
requester: None,
|
||||||
|
network: None,
|
||||||
|
stars: None,
|
||||||
|
categories: None,
|
||||||
|
duration: None,
|
||||||
|
sort: None,
|
||||||
|
sexuality: None,
|
||||||
|
};
|
||||||
|
let items = p.parse_items(html, &opts);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.id, "12345");
|
||||||
|
assert_eq!(item.title, "Test Video Title");
|
||||||
|
assert_eq!(item.thumb, "https://ei-ph.t8cdn.com/videos/test/thumb.jpg");
|
||||||
|
assert_eq!(item.duration, 510);
|
||||||
|
assert_eq!(item.views, Some(12300));
|
||||||
|
assert_eq!(item.rating, Some(87.0));
|
||||||
|
assert_eq!(item.uploader.as_deref(), Some("TestChannel"));
|
||||||
|
assert!(item
|
||||||
|
.tags
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|t| t.iter().any(|v| v == "Jane Doe")));
|
||||||
|
assert!(item
|
||||||
|
.preview
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.contains("_fb.mp4")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use crate::proxies::doodstream::DoodstreamProxy;
|
|||||||
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
||||||
use crate::proxies::hqporner::HqpornerProxy;
|
use crate::proxies::hqporner::HqpornerProxy;
|
||||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||||
|
use crate::proxies::tube8::Tube8Proxy;
|
||||||
use ntex::web;
|
use ntex::web;
|
||||||
|
|
||||||
use crate::proxies::pimpbunny::PimpbunnyProxy;
|
use crate::proxies::pimpbunny::PimpbunnyProxy;
|
||||||
@@ -37,6 +38,7 @@ pub mod shooshtime;
|
|||||||
pub mod spankbang;
|
pub mod spankbang;
|
||||||
pub mod sxyprn;
|
pub mod sxyprn;
|
||||||
pub mod thaiporntv;
|
pub mod thaiporntv;
|
||||||
|
pub mod tube8;
|
||||||
pub mod vidara;
|
pub mod vidara;
|
||||||
pub mod vjav;
|
pub mod vjav;
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ pub enum AnyProxy {
|
|||||||
Vidara(VidaraProxy),
|
Vidara(VidaraProxy),
|
||||||
Clapdat(ClapdatProxy),
|
Clapdat(ClapdatProxy),
|
||||||
ThaipornTv(ThaipornTvProxy),
|
ThaipornTv(ThaipornTvProxy),
|
||||||
|
Tube8(Tube8Proxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Proxy {
|
pub trait Proxy {
|
||||||
@@ -85,6 +88,7 @@ impl Proxy for AnyProxy {
|
|||||||
AnyProxy::Vidara(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Vidara(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Clapdat(p) => p.get_video_url(url, requester).await,
|
AnyProxy::Clapdat(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await,
|
AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await,
|
||||||
|
AnyProxy::Tube8(p) => p.get_video_url(url, requester).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
src/proxies/tube8.rs
Normal file
173
src/proxies/tube8.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
use ntex::web;
|
||||||
|
|
||||||
|
use crate::util::requester::Requester;
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://www.tube8.com";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tube8Proxy {}
|
||||||
|
|
||||||
|
impl Tube8Proxy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Tube8Proxy {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_headers() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"User-Agent".to_string(),
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"
|
||||||
|
.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()),
|
||||||
|
("Referer".to_string(), format!("{BASE_URL}/")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_headers(referer: &str) -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"User-Agent".to_string(),
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
("Accept".to_string(), "application/json, text/javascript, */*; q=0.01".to_string()),
|
||||||
|
("Referer".to_string(), referer.to_string()),
|
||||||
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the first /media/hls/?s=... URL from a video page.
|
||||||
|
// The page embeds it as: "videoUrl":"https:\/\/www.tube8.com\/media\/hls\/?s=TOKEN"
|
||||||
|
fn extract_hls_endpoint(html: &str) -> Option<String> {
|
||||||
|
let needle = r#""format":"hls","videoUrl":""#;
|
||||||
|
let start = html.find(needle)? + needle.len();
|
||||||
|
let rest = &html[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
let raw = &rest[..end];
|
||||||
|
// JSON-escaped forward slashes → real URL
|
||||||
|
Some(raw.replace(r"\/", "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON quality array returned by /media/hls/?s=...
|
||||||
|
// Returns the highest-quality HLS master playlist URL.
|
||||||
|
fn best_hls_url(json: &str) -> Option<String> {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||||
|
let arr = parsed.as_array()?;
|
||||||
|
|
||||||
|
// Prefer highest numeric quality; fall back to defaultQuality
|
||||||
|
let mut best_quality: i64 = -1;
|
||||||
|
let mut best_url: Option<String> = None;
|
||||||
|
let mut default_url: Option<String> = None;
|
||||||
|
|
||||||
|
for entry in arr {
|
||||||
|
let url = entry
|
||||||
|
.get("videoUrl")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|v| v.replace(r"\/", "/"))
|
||||||
|
.filter(|v| !v.is_empty())?;
|
||||||
|
|
||||||
|
if entry
|
||||||
|
.get("defaultQuality")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& default_url.is_none()
|
||||||
|
{
|
||||||
|
default_url = Some(url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(q) = entry
|
||||||
|
.get("quality")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
{
|
||||||
|
if q > best_quality {
|
||||||
|
best_quality = q;
|
||||||
|
best_url = Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_url.or(default_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_video_url(
|
||||||
|
&self,
|
||||||
|
video_id: String,
|
||||||
|
requester: web::types::State<Requester>,
|
||||||
|
) -> String {
|
||||||
|
let video_id = video_id.trim_matches('/').trim();
|
||||||
|
if video_id.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_url = format!("{BASE_URL}/porn-video/{video_id}/");
|
||||||
|
let mut req = requester.get_ref().clone();
|
||||||
|
|
||||||
|
// Step 1: fetch video page to get the signed /media/hls/ endpoint
|
||||||
|
let html = match req
|
||||||
|
.get_with_headers(&page_url, Self::html_headers(), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let hls_endpoint = match Self::extract_hls_endpoint(&html) {
|
||||||
|
Some(url) => url,
|
||||||
|
None => return String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: call the signed endpoint to get quality options
|
||||||
|
let json = match req
|
||||||
|
.get_with_headers(&hls_endpoint, Self::api_headers(&page_url), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::best_hls_url(&json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Tube8Proxy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_hls_endpoint_from_page() {
|
||||||
|
let html = r#"
|
||||||
|
mediaDefinition: [{"format":"hls","videoUrl":"https:\/\/www.tube8.com\/media\/hls\/?s=eyJTOKEN","remote":true},
|
||||||
|
{"format":"mp4","videoUrl":"https:\/\/www.tube8.com\/media\/mp4\/?s=eyJTOKEN","remote":true}],
|
||||||
|
"#;
|
||||||
|
let url = Tube8Proxy::extract_hls_endpoint(html).expect("should extract");
|
||||||
|
assert_eq!(url, "https://www.tube8.com/media/hls/?s=eyJTOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_best_hls_quality() {
|
||||||
|
let json = r#"[
|
||||||
|
{"defaultQuality":true,"format":"hls","quality":"480","videoUrl":"https://cdn.example/480/master.m3u8"},
|
||||||
|
{"defaultQuality":false,"format":"hls","quality":"720","videoUrl":"https://cdn.example/720/master.m3u8"},
|
||||||
|
{"defaultQuality":false,"format":"hls","quality":"1080","videoUrl":"https://cdn.example/1080/master.m3u8"},
|
||||||
|
{"defaultQuality":false,"format":"hls","quality":"240","videoUrl":"https://cdn.example/240/master.m3u8"}
|
||||||
|
]"#;
|
||||||
|
let url = Tube8Proxy::best_hls_url(json).expect("should parse");
|
||||||
|
assert_eq!(url, "https://cdn.example/1080/master.m3u8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_to_default_quality_when_no_numeric() {
|
||||||
|
let json = r#"[
|
||||||
|
{"defaultQuality":true,"format":"hls","videoUrl":"https://cdn.example/default/master.m3u8"},
|
||||||
|
{"defaultQuality":false,"format":"hls","videoUrl":"https://cdn.example/other/master.m3u8"}
|
||||||
|
]"#;
|
||||||
|
let url = Tube8Proxy::best_hls_url(json).expect("should parse");
|
||||||
|
assert_eq!(url, "https://cdn.example/default/master.m3u8");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use crate::proxies::pornhd3x::Pornhd3xProxy;
|
|||||||
use crate::proxies::shooshtime::ShooshtimeProxy;
|
use crate::proxies::shooshtime::ShooshtimeProxy;
|
||||||
use crate::proxies::spankbang::SpankbangProxy;
|
use crate::proxies::spankbang::SpankbangProxy;
|
||||||
use crate::proxies::sxyprn::SxyprnProxy;
|
use crate::proxies::sxyprn::SxyprnProxy;
|
||||||
|
use crate::proxies::tube8::Tube8Proxy;
|
||||||
use crate::proxies::vjav::VjavProxy;
|
use crate::proxies::vjav::VjavProxy;
|
||||||
use crate::proxies::vidara::VidaraProxy;
|
use crate::proxies::vidara::VidaraProxy;
|
||||||
use crate::proxies::lulustream::LulustreamProxy;
|
use crate::proxies::lulustream::LulustreamProxy;
|
||||||
@@ -136,6 +137,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("/tube8/{endpoint}*")
|
||||||
|
.route(web::post().to(proxy2redirect))
|
||||||
|
.route(web::get().to(proxy2redirect)),
|
||||||
|
);
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::resource("/aps/{endpoint}*")
|
web::resource("/aps/{endpoint}*")
|
||||||
.route(web::post().to(crate::proxies::allpornstream::serve))
|
.route(web::post().to(crate::proxies::allpornstream::serve))
|
||||||
@@ -177,6 +183,7 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
|||||||
"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())),
|
"allpornstream" => Some(AnyProxy::AllPornStream(AllPornStreamProxy::new())),
|
||||||
|
"tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user