tube8
This commit is contained in:
5
build.rs
5
build.rs
@@ -306,6 +306,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
||||
module: "allpornstream",
|
||||
ty: "AllPornStreamProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "tube8",
|
||||
module: "tube8",
|
||||
ty: "Tube8Provider",
|
||||
},
|
||||
];
|
||||
|
||||
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. |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -82,6 +83,7 @@ These resolve a provider-specific input into a `302 Location`.
|
||||
- `/proxy/shooshtime/{endpoint}*`
|
||||
- `/proxy/pimpbunny/{endpoint}*`
|
||||
- `/proxy/allpornstream/{endpoint}*`
|
||||
- `/proxy/tube8/{endpoint}*`
|
||||
|
||||
### 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::hqporner::HqpornerProxy;
|
||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||
use crate::proxies::tube8::Tube8Proxy;
|
||||
use ntex::web;
|
||||
|
||||
use crate::proxies::pimpbunny::PimpbunnyProxy;
|
||||
@@ -37,6 +38,7 @@ pub mod shooshtime;
|
||||
pub mod spankbang;
|
||||
pub mod sxyprn;
|
||||
pub mod thaiporntv;
|
||||
pub mod tube8;
|
||||
pub mod vidara;
|
||||
pub mod vjav;
|
||||
|
||||
@@ -59,6 +61,7 @@ pub enum AnyProxy {
|
||||
Vidara(VidaraProxy),
|
||||
Clapdat(ClapdatProxy),
|
||||
ThaipornTv(ThaipornTvProxy),
|
||||
Tube8(Tube8Proxy),
|
||||
}
|
||||
|
||||
pub trait Proxy {
|
||||
@@ -85,6 +88,7 @@ impl Proxy for AnyProxy {
|
||||
AnyProxy::Vidara(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::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::spankbang::SpankbangProxy;
|
||||
use crate::proxies::sxyprn::SxyprnProxy;
|
||||
use crate::proxies::tube8::Tube8Proxy;
|
||||
use crate::proxies::vjav::VjavProxy;
|
||||
use crate::proxies::vidara::VidaraProxy;
|
||||
use crate::proxies::lulustream::LulustreamProxy;
|
||||
@@ -136,6 +137,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::post().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(
|
||||
web::resource("/aps/{endpoint}*")
|
||||
.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())),
|
||||
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
||||
"allpornstream" => Some(AnyProxy::AllPornStream(AllPornStreamProxy::new())),
|
||||
"tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user