diff --git a/build.rs b/build.rs index e83e489..980a0dc 100644 --- a/build.rs +++ b/build.rs @@ -306,6 +306,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "allpornstream", ty: "AllPornStreamProvider", }, + ProviderDef { + id: "tube8", + module: "tube8", + ty: "Tube8Provider", + }, ]; fn main() { diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index 43984bb..c59f3a5 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -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 diff --git a/src/providers/tube8.rs b/src/providers/tube8.rs new file mode 100644 index 0000000..d8b77e2 --- /dev/null +++ b/src/providers/tube8.rs @@ -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::parse(value).ok() + } + + fn normalize_text(value: &str) -> String { + decode(value.as_bytes()) + .to_string() + .unwrap_or_else(|_| value.to_string()) + .split_whitespace() + .collect::>() + .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>) -> String { + node.map(|v| Self::normalize_text(&v.text().collect::())) + .unwrap_or_default() + } + + fn parse_items(&self, html: &str, options: &ServerOptions) -> Vec { + 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::())) + .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::())) + .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::()).replace('%', "") + }) + .and_then(|v| v.parse::().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::())) + .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 = Vec::new(); + if let Some(sel) = &performer_sel { + for p in card.select(sel) { + let t = Self::normalize_text(&p.text().collect::()); + 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, + page: String, + _per_page: String, + options: ServerOptions, + ) -> Vec { + let page = page.parse::().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 { + 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#" + + "#; + + 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"))); + } +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 7840aaf..5ebe15c 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -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, } } } diff --git a/src/proxies/tube8.rs b/src/proxies/tube8.rs new file mode 100644 index 0000000..4506906 --- /dev/null +++ b/src/proxies/tube8.rs @@ -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 { + 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 { + 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 = None; + let mut default_url: Option = 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::().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, + ) -> 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"); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index 8626189..7cb9b50 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -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 { "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, } }