pimpbunny and more fixes

This commit is contained in:
Simon
2026-05-18 16:52:25 +00:00
committed by ForgeCode
parent dc70e2c9a6
commit ee47bbe74d
13 changed files with 1457 additions and 311 deletions

View File

@@ -20,7 +20,7 @@ ntex-files = "3"
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
tokio = { version = "1.49", features = ["full"] } tokio = { version = "1.49", features = ["full"] }
wreq = { version = "5", features = ["cookies", "multipart", "json"] } wreq = { version = "5.3.0", features = ["cookies", "multipart", "json"] }
wreq-util = "2" wreq-util = "2"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
capitalize = "0.3.4" capitalize = "0.3.4"
@@ -37,6 +37,8 @@ lru = "0.18.0"
rand = "0.10.0" rand = "0.10.0"
chrono = "0.4.44" chrono = "0.4.44"
md5 = "0.8.0" md5 = "0.8.0"
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
playwright = "0.0.20"
[lints.rust] [lints.rust]
warnings = "warn" warnings = "warn"

View File

@@ -296,6 +296,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "archivebate1", module: "archivebate1",
ty: "ArchivebateProvider", ty: "ArchivebateProvider",
}, },
ProviderDef {
id: "thaiporntv",
module: "thaiporntv",
ty: "ThaipornTvProvider",
},
]; ];
fn main() { fn main() {

View File

@@ -48,6 +48,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `sextb` | `jav` | no | no | JAV family provider. | | `sextb` | `jav` | no | no | JAV family provider. |
| `shooshtime` | `onlyfans` | no | yes | Redirect proxy plus dedicated media route. | | `shooshtime` | `onlyfans` | no | yes | Redirect proxy plus dedicated media route. |
| `spankbang` | `mainstream-tube` | no | yes | Best template for redirect proxy plus anti-bot fetches. | | `spankbang` | `mainstream-tube` | no | yes | Best template for redirect proxy plus anti-bot fetches. |
| `thaiporntv` | `mainstream-tube` | no | yes | Decodes `data-enc` attribute for proxied HLS playback. |
| `supjav` | `jav` | no | no | JAV/HLS and uploader-id examples. | | `supjav` | `jav` | no | no | JAV/HLS and uploader-id examples. |
| `sxyprn` | `mainstream-tube` | no | yes | Redirect proxy helper usage. | | `sxyprn` | `mainstream-tube` | no | yes | Redirect proxy helper usage. |
| `tnaflix` | `mainstream-tube` | no | no | Mainstream tube provider. | | `tnaflix` | `mainstream-tube` | no | no | Mainstream tube provider. |

View File

@@ -1,4 +1,4 @@
Implement a new Hottub provider for `archivebate1` at `https://archivebate1.com`. Implement a new Hottub provider for `thaiporntv` at `https://www.thaiporntv.com`.
You are working inside the Hottub Rust server. Your job is to add a functioning provider module that can survive handoff to another model with minimal guesswork. Do not stop at code generation. Carry the work through code, validation, and documentation updates. You are working inside the Hottub Rust server. Your job is to add a functioning provider module that can survive handoff to another model with minimal guesswork. Do not stop at code generation. Carry the work through code, validation, and documentation updates.

784
src/providers/thaiporntv.rs Normal file
View File

@@ -0,0 +1,784 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, report_provider_error_background, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use base64::{engine::general_purpose, Engine};
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use regex::Regex;
use scraper::{ElementRef, Html, Selector};
use std::sync::{Arc, RwLock};
use std::time::Duration as StdDuration;
use std::{thread, vec};
use tokio::time::timeout;
use url::Url;
use wreq::Version;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["thai", "asian", "amateur"],
};
error_chain! {
foreign_links {
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
const BASE_URL: &str = "https://www.thaiporntv.com";
const CHANNEL_ID: &str = "thaiporntv";
const USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
const HTML_ACCEPT: &str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
#[derive(Debug, Clone)]
pub struct ThaipornTvProvider {
url: String,
tags: Arc<RwLock<Vec<FilterOption>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArchiveMode {
Latest,
Popular,
Commented,
Rated,
Longest,
}
#[derive(Debug, Clone)]
enum Target {
Archive(ArchiveMode),
Search {
query: String,
},
Tag {
slug: String,
},
}
impl ThaipornTvProvider {
pub fn new() -> Self {
let provider = Self {
url: BASE_URL.to_string(),
tags: Arc::new(RwLock::new(vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}])),
};
provider.spawn_initial_load();
provider
}
fn spawn_initial_load(&self) {
let url = self.url.clone();
let tags = Arc::clone(&self.tags);
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"spawn_initial_load.runtime_build",
&error.to_string(),
);
return;
}
};
runtime.block_on(async move {
if let Err(error) = Self::load_tags(&url, Arc::clone(&tags)).await {
report_provider_error_background(
CHANNEL_ID,
"load_tags",
&error.to_string(),
);
}
});
});
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let tags = self.tags.read().map(|value| value.clone()).unwrap_or_default();
Channel {
id: CHANNEL_ID.to_string(),
name: "THAIPornTV".to_string(),
description: "Free Thai Porn Videos & Asian Sex Tube.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=thaiporntv.com".to_string(),
status: "active".to_string(),
categories: tags.iter().map(|value| value.title.clone()).collect(),
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Sort the videos".to_string(),
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption {
id: "new".to_string(),
title: "Most Recent".to_string(),
},
FilterOption {
id: "popular".to_string(),
title: "Most Viewed".to_string(),
},
FilterOption {
id: "commented".to_string(),
title: "Most Commented".to_string(),
},
FilterOption {
id: "rated".to_string(),
title: "Top Rated".to_string(),
},
FilterOption {
id: "longest".to_string(),
title: "Longest".to_string(),
},
],
multiSelect: false,
},
ChannelOption {
id: "filter".to_string(),
title: "Tags".to_string(),
description: "Video tags.".to_string(),
systemImage: "tag.fill".to_string(),
colorName: "green".to_string(),
options: tags,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|error| Error::from(format!("selector `{value}` parse failed: {error}")))
}
fn regex(value: &str) -> Result<Regex> {
Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}")))
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn decode_html_entities(text: &str) -> String {
decode(text.as_bytes())
.to_string()
.unwrap_or_else(|_| text.to_string())
.replace('\u{a0}', " ")
.trim()
.to_string()
}
fn absolute_url(&self, value: &str) -> String {
if value.starts_with("http://") || value.starts_with("https://") {
return value.to_string();
}
format!(
"{}/{}",
self.url.trim_end_matches('/'),
value.trim_start_matches('/')
)
}
fn html_headers(&self, referer: &str) -> Vec<(String, String)> {
vec![
("Referer".to_string(), referer.to_string()),
("User-Agent".to_string(), USER_AGENT.to_string()),
("Accept".to_string(), HTML_ACCEPT.to_string()),
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
]
}
async fn fetch_html(
&self,
requester: &mut Requester,
url: &str,
referer: &str,
) -> Result<String> {
requester
.get_with_headers(url, self.html_headers(referer), Some(Version::HTTP_11))
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
}
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
if item.id.is_empty() || item.title.is_empty() {
return;
}
if let Ok(mut values) = target.write() {
if !values
.iter()
.any(|existing| existing.id == item.id || existing.title == item.title)
{
values.push(item);
}
}
}
async fn load_tags(base_url: &str, tags: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
let mut requester = Requester::new();
let provider = Self {
url: base_url.to_string(),
tags: Arc::clone(&tags),
};
let html = provider.fetch_html(&mut requester, &format!("{}/tags/", base_url), &format!("{}/", base_url)).await?;
let document = Html::parse_document(&html);
let selector = Self::selector("a[href*='/tags/']")?;
for element in document.select(&selector) {
let Some(href) = element.value().attr("href") else {
continue;
};
let title = Self::decode_html_entities(&element.text().collect::<String>());
let re = Regex::new(r"^(.+?)\s+\d+$").unwrap(); // Remove count from tag title
let title = if let Some(captures) = re.captures(&title) {
captures.get(1).unwrap().as_str().to_string()
} else {
title
};
if title.is_empty() {
continue;
}
Self::push_unique(
&tags,
FilterOption {
id: href.to_string(),
title,
},
);
}
Ok(())
}
fn archive_from_sort(sort: &str) -> ArchiveMode {
match sort {
"popular" => ArchiveMode::Popular,
"commented" => ArchiveMode::Commented,
"rated" => ArchiveMode::Rated,
"longest" => ArchiveMode::Longest,
_ => ArchiveMode::Latest,
}
}
fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target {
if let Some(value) = options.filter.as_deref() {
if let Some(target) = self.find_tag_target_in_options(value) {
return target;
}
}
Target::Archive(Self::archive_from_sort(sort))
}
fn resolve_query_target(&self, query: &str) -> Target {
if let Some(target) = self.find_tag_target_in_options(query) {
return target;
}
Target::Search {
query: query.trim().to_string(),
}
}
fn find_tag_target_in_options(
&self,
value: &str,
) -> Option<Target> {
let normalized = value.trim().to_lowercase();
let tags = self.tags.read().ok()?;
let option = tags.iter().find(|item| {
item.id.eq_ignore_ascii_case(value) || item.title.trim().to_lowercase() == normalized
})?;
self.target_from_filter_id(&option.id)
}
fn target_from_filter_id(&self, id: &str) -> Option<Target> {
if id.contains("/tags/") {
let url = Url::parse(&self.absolute_url(id)).ok()?;
let path_segments = url.path_segments()?;
let slug = path_segments.last()?.trim_end_matches('/').to_string();
return Some(Target::Tag { slug });
}
None
}
fn build_url_for_target(&self, target: &Target, page: u32) -> String {
match target {
Target::Archive(mode) => self.build_archive_url(*mode, page),
Target::Search { query } => self.build_search_url(query, page),
Target::Tag { slug } => self.build_tag_url(slug, page),
}
}
fn build_archive_url(&self, mode: ArchiveMode, page: u32) -> String {
let base_path = match mode {
ArchiveMode::Latest => "recent",
ArchiveMode::Popular => "popular",
ArchiveMode::Commented => "commented",
ArchiveMode::Rated => "rated",
ArchiveMode::Longest => "longest",
};
if page <= 1 {
format!("{}/{}/", self.url, base_path)
} else {
format!("{}/{}/page/{}/", self.url, base_path, page)
}
}
fn build_search_url(&self, query: &str, page: u32) -> String {
let encoded_query = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
if page <= 1 {
format!("{}/search/?q={}", self.url, encoded_query)
} else {
format!("{}/search/?q={}&page={}", self.url, encoded_query, page)
}
}
fn build_tag_url(&self, slug: &str, page: u32) -> String {
let encoded_slug = utf8_percent_encode(slug, NON_ALPHANUMERIC).to_string();
if page <= 1 {
format!("{}/tags/{}/", self.url, encoded_slug)
} else {
format!("{}/tags/{}/page/{}/", self.url, encoded_slug, page)
}
}
fn decode_data_enc(encoded_data: &str) -> Result<Vec<VideoFormat>> {
let cleaned_data = encoded_data.replace("-", "+").replace("_", "/");
let padded_data = format!("{:<pad$}", cleaned_data, pad = (cleaned_data.len() + 3) & !3);
let decoded_bytes = general_purpose::STANDARD.decode(&padded_data)
.map_err(|e| Error::from(format!("Base64 decode failed: {e}")))?;
if decoded_bytes.is_empty() {
return Err(Error::from("Decoded bytes are empty"));
}
let key = decoded_bytes[0];
let decrypted_bytes: Vec<u8> = decoded_bytes[1..]
.iter()
.map(|&b| b ^ key)
.collect();
let json_str = String::from_utf8(decrypted_bytes)
.map_err(|e| Error::from(format!("UTF-8 decode failed: {e}")))?;
let formats_json: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| Error::from(format!("JSON parse failed: {e}")))?;
let Some(formats_array) = formats_json.as_array() else {
return Err(Error::from("JSON is not an array"));
};
let mut formats = Vec::new();
for format_val in formats_array {
let Some(u) = format_val.get("u").and_then(|v| v.as_str()) else { continue; };
let Some(q) = format_val.get("q").and_then(|v| v.as_str()) else { continue; };
let mut format = VideoFormat::new(
u.to_string(),
q.to_string(),
"application/x-mpegURL".to_string(), // Assuming m3u8
);
// Add referer to the format
format.add_http_header("Referer".to_string(), BASE_URL.to_string());
formats.push(format);
}
Ok(formats)
}
fn parse_card(
&self,
card: ElementRef<'_>,
_proxy_base_url: &str,
) -> Option<VideoItem> {
let id_selector = Self::selector("a[href*='/videos/']").ok()?;
let title_selector = Self::selector("a[href*='/videos/']").ok()?;
let thumb_selector = Self::selector("img").ok()?;
let duration_selector = Self::selector("div.duration").ok()?;
let views_selector = Self::selector("div.views").ok()?;
let uploaded_at_selector = Self::selector("div.date").ok()?;
let tag_selector = Self::selector("a[href*='/tags/']").ok()?;
let href_element = card.select(&id_selector).next()?;
let href = href_element.value().attr("href")?.to_string();
let re = Regex::new(r"/videos/\d{4}/[^/-]+-(\d+)/$").unwrap();
let captures = re.captures(&href)?;
let id = captures.get(1)?.as_str().to_string();
let title = card.select(&title_selector).next()
.and_then(|e| e.value().attr("title"))
.map(Self::decode_html_entities)
.unwrap_or_else(|| {
card.select(&thumb_selector).next()
.and_then(|e| e.value().attr("alt"))
.map(Self::decode_html_entities)
.unwrap_or_default()
});
let thumb = card.select(&thumb_selector).next()
.and_then(|e| e.value().attr("src"))
.map(|s| self.absolute_url(s))
.unwrap_or_default();
let duration_text = card.select(&duration_selector).next()
.map(|e| Self::collapse_whitespace(&e.text().collect::<String>()))
.unwrap_or_default();
let duration = parse_time_to_seconds(&duration_text).unwrap_or(0) as u32;
let views = card.select(&views_selector).next()
.map(|e| Self::collapse_whitespace(&e.text().collect::<String>()))
.and_then(|s| s.strip_suffix(" views").map(|s| parse_abbreviated_number(s)))
.flatten();
let uploaded_at_text = card.select(&uploaded_at_selector).next()
.map(|e| Self::collapse_whitespace(&e.text().collect::<String>()))
.unwrap_or_default();
let uploaded_at = NaiveDate::parse_from_str(&uploaded_at_text, "%d %b %Y")
.ok()
.and_then(|date| {
date.and_hms_opt(0, 0, 0)
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).timestamp() as u64)
});
let tags: Vec<String> = card.select(&tag_selector)
.filter_map(|e| e.value().attr("href"))
.filter_map(|link_href| {
Url::parse(&self.absolute_url(link_href))
.ok()
.and_then(|url| url.path_segments().map(|segments| segments.map(ToString::to_string).collect::<Vec<String>>()))
.and_then(|segments_vec| segments_vec.last().cloned())
.map(|s| Self::decode_html_entities(&s).trim_end_matches('/').to_string())
})
.collect();
let mut item = VideoItem::new(
id,
title,
self.absolute_url(&href),
CHANNEL_ID.to_string(),
thumb,
duration,
);
if let Some(views) = views { item = item.views(views); }
if let Some(uploaded_at) = uploaded_at { item = item.uploaded_at(uploaded_at); }
if !tags.is_empty() { item = item.tags(tags); }
Some(item)
}
fn get_video_items_from_html(&self, html: String, proxy_base_url: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(&html);
let card_selector = Self::selector("div.video-list-item")?;
let mut items = Vec::new();
for card in document.select(&card_selector) {
if let Some(item) = self.parse_card(card, proxy_base_url) {
items.push(item);
}
}
Ok(items)
}
async fn enrich_video(&self, mut item: VideoItem, options: &ServerOptions) -> Result<VideoItem> {
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_video");
let detail_fetch = timeout(
StdDuration::from_secs(6),
self.fetch_html(&mut requester, &item.url, &item.url),
)
.await
.map_err(|_| Error::from(format!("detail request timed out for {}", item.url)))??;
let document = Html::parse_document(&detail_fetch);
let video_element_selector = Self::selector("video[data-enc]")?;
if let Some(video_element) = document.select(&video_element_selector).next() {
if let Some(data_enc) = video_element.value().attr("data-enc") {
match Self::decode_data_enc(data_enc) {
Ok(formats) => {
item.formats = Some(formats);
},
Err(e) => {
report_provider_error_background(CHANNEL_ID, "decode_data_enc", &format!("url={}; error={}", item.url, e));
}
}
}
}
Ok(item)
}
async fn fetch_items_for_url(
&self,
cache: VideoCache,
url: String,
per_page_limit: usize,
enrich_details: bool,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
if let Some((time, items)) = cache.get(&url) {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
return Ok(items.clone());
}
}
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url");
let html = timeout(
StdDuration::from_secs(10),
self.fetch_html(&mut requester, &url, &url),
)
.await
.map_err(|_| Error::from(format!("list request timed out for {url}")))??;
let list_items = self.get_video_items_from_html(html, options.public_url_base.as_deref().unwrap_or_default())?;
if list_items.is_empty() {
return Ok(vec![]);
}
let limited_items = list_items
.into_iter()
.take(per_page_limit.max(1))
.collect::<Vec<_>>();
if !enrich_details {
cache.insert(url, limited_items.clone());
return Ok(limited_items);
}
let items = stream::iter(limited_items.into_iter().map(|item| {
let provider = self.clone();
let options = options.clone();
async move { provider.enrich_video(item, &options).await }
}))
.buffer_unordered(4)
.collect::<Vec<_>>()
.await;
let mut final_items = Vec::new();
for item_result in items {
match item_result {
Ok(item) => final_items.push(item),
Err(e) => report_provider_error_background(CHANNEL_ID, "enrich_video_stream", &e.to_string()),
}
}
if !final_items.is_empty() {
cache.insert(url, final_items.clone());
}
Ok(final_items)
}
async fn get(
&self,
cache: VideoCache,
page: u32,
sort: &str,
per_page_limit: usize,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let target = self.resolve_option_target(&options, sort);
let url = self.build_url_for_target(&target, page);
self.fetch_items_for_url(cache, url, per_page_limit, page <= 1, &options)
.await
}
async fn query(
&self,
cache: VideoCache,
page: u32,
query: &str,
per_page_limit: usize,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let target = self.resolve_query_target(query);
let url = self.build_url_for_target(&target, page);
self.fetch_items_for_url(cache, url, per_page_limit, page <= 1, &options)
.await
}
}
#[async_trait]
impl Provider for ThaipornTvProvider {
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 page = page.parse::<u32>().unwrap_or(1);
let per_page_limit = per_page.parse::<usize>().unwrap_or(30);
let result = match query {
Some(query) if !query.trim().is_empty() => {
self.query(cache, page, &query, per_page_limit, options)
.await
}
_ => self.get(cache, page, &sort, per_page_limit, options).await,
};
match result {
Ok(videos) => videos,
Err(error) => {
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::cache::VideoCache;
use crate::util::requester::Requester;
fn provider() -> ThaipornTvProvider {
ThaipornTvProvider::new()
}
#[test]
fn builds_archive_urls() {
let provider = provider();
assert_eq!(
provider.build_archive_url(ArchiveMode::Latest, 1),
"https://www.thaiporntv.com/recent/"
);
assert_eq!(
provider.build_archive_url(ArchiveMode::Latest, 2),
"https://www.thaiporntv.com/recent/page/2/"
);
assert_eq!(
provider.build_archive_url(ArchiveMode::Popular, 1),
"https://www.thaiporntv.com/popular/"
);
assert_eq!(
provider.build_archive_url(ArchiveMode::Popular, 3),
"https://www.thaiporntv.com/popular/page/3/"
);
}
#[test]
fn builds_search_urls() {
let provider = provider();
assert_eq!(
provider.build_search_url("thai student", 1),
"https://www.thaiporntv.com/search/?q=thai%20student"
);
assert_eq!(
provider.build_search_url("thai student", 2),
"https://www.thaiporntv.com/search/?q=thai%20student&page=2"
);
}
#[test]
fn builds_tag_urls() {
let provider = provider();
assert_eq!(
provider.build_tag_url("blowjob", 1),
"https://www.thaiporntv.com/tags/blowjob/"
);
assert_eq!(
provider.build_tag_url("thai-massage", 2),
"https://www.thaiporntv.com/tags/thai-massage/page/2/"
);
}
#[test]
fn decodes_data_enc_correctly() {
let encoded = "C1BwKX4pMSljf397eDEkJHxuaSV_bmhjfWJveCV_ZHskZjh-MyQ6PT4zVD8zO3slZjh-MyknKXopMSk_Mzt7KScpYykxbWpneG52Vg";
let formats = ThaipornTvProvider::decode_data_enc(encoded).unwrap();
assert_eq!(formats.len(), 1);
assert_eq!(formats[0].url, "https://web.techvids.top/m3u8/1658_480p.m3u8");
assert_eq!(formats[0].quality, "480p");
assert_eq!(formats[0].http_headers.get("Referer").unwrap(), "https://www.thaiporntv.com");
}
#[tokio::test]
#[ignore]
async fn fetches_and_parses_archive() {
let provider = provider();
let options = ServerOptions {
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: Some("http://127.0.0.1:18080".to_string()),
requester: Some(Requester::new()),
network: None,
stars: None,
categories: None,
duration: None,
sort: Some("new".to_string()),
sexuality: None,
};
let videos = provider.get(VideoCache::new(), 1, "new", 10, options).await.unwrap();
assert!(!videos.is_empty());
// Further assertions on video content
}
#[tokio::test]
#[ignore]
async fn fetches_and_parses_search() {
let provider = provider();
let options = ServerOptions {
featured: None,
category: None,
sites: None,
filter: None,
language: None,
public_url_base: Some("http://127.0.0.1:18080".to_string()),
requester: Some(Requester::new()),
network: None,
stars: None,
categories: None,
duration: None,
sort: Some("new".to_string()),
sexuality: None,
};
let videos = provider.query(VideoCache::new(), 1, "thai student", 10, options).await.unwrap();
assert!(!videos.is_empty());
// Further assertions on video content
}
}

View File

@@ -14,6 +14,7 @@ use crate::proxies::vjav::VjavProxy;
use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester}; use crate::{proxies::sxyprn::SxyprnProxy, util::requester::Requester};
use crate::proxies::vidara::VidaraProxy; use crate::proxies::vidara::VidaraProxy;
use crate::proxies::lulustream::LulustreamProxy; use crate::proxies::lulustream::LulustreamProxy;
use crate::proxies::thaiporntv::ThaipornTvProxy;
pub mod archivebate; pub mod archivebate;
pub mod clapdat; pub mod clapdat;
@@ -33,6 +34,7 @@ pub mod pornhubthumb;
pub mod shooshtime; pub mod shooshtime;
pub mod spankbang; pub mod spankbang;
pub mod sxyprn; pub mod sxyprn;
pub mod thaiporntv;
pub mod vidara; pub mod vidara;
pub mod vjav; pub mod vjav;
@@ -53,6 +55,7 @@ pub enum AnyProxy {
Vjav(VjavProxy), Vjav(VjavProxy),
Vidara(VidaraProxy), Vidara(VidaraProxy),
Clapdat(ClapdatProxy), Clapdat(ClapdatProxy),
ThaipornTv(ThaipornTvProxy),
} }
pub trait Proxy { pub trait Proxy {
@@ -77,6 +80,7 @@ impl Proxy for AnyProxy {
AnyProxy::Vjav(p) => p.get_video_url(url, requester).await, AnyProxy::Vjav(p) => p.get_video_url(url, requester).await,
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,
} }
} }
} }

View File

@@ -1,33 +1,18 @@
use crate::util::browser;
use crate::util::requester::Requester; use crate::util::requester::Requester;
use ntex::web; use ntex::web;
use regex::Regex; use regex::Regex;
use std::{collections::HashMap, time::{SystemTime, UNIX_EPOCH}}; use std::time::{SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
use wreq::Version;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PimpbunnyProxy {} pub struct PimpbunnyProxy {}
impl PimpbunnyProxy { impl PimpbunnyProxy {
const FIREFOX_USER_AGENT: &'static str =
"Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0";
const HTML_ACCEPT: &'static str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
/// Site-wide fallback key only used when the page omits a license_code.
const LICENSE_CODE: &'static str = "$576262819011919";
pub fn new() -> Self { pub fn new() -> Self {
PimpbunnyProxy {} PimpbunnyProxy {}
} }
fn js_now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
fn normalize_detail_url(url: &str) -> Option<String> { fn normalize_detail_url(url: &str) -> Option<String> {
let normalized = if url.starts_with("http://") || url.starts_with("https://") { let normalized = if url.starts_with("http://") || url.starts_with("https://") {
url.to_string() url.to_string()
@@ -38,7 +23,7 @@ impl PimpbunnyProxy {
} }
fn is_allowed_detail_url(url: &str) -> bool { fn is_allowed_detail_url(url: &str) -> bool {
let Some(url) = Url::parse(url).ok() else { let Ok(url) = Url::parse(url) else {
return false; return false;
}; };
if url.scheme() != "https" { if url.scheme() != "https" {
@@ -51,253 +36,182 @@ impl PimpbunnyProxy {
&& !url.path().starts_with("/contents/videos_screenshots/") && !url.path().starts_with("/contents/videos_screenshots/")
} }
fn root_referer() -> &'static str { /// Generate 32-char numeric key from a pimpbunny license_code (e.g. '$576262819011919').
"https://pimpbunny.com/" ///
} /// Stage 1 of the KVS player URL decoder, extracted from kt_player.js:
/// - d_raw = license_code (keeps '$' at index 0 for the d[g+h] lookup)
fn html_headers_with_referer(referer: &str) -> Vec<(String, String)> { /// - d_mod = strip '$', replace every '0' digit with '1'
vec![ /// - split d_mod at midpoint j = floor(len/2) to get k and l
("Referer".to_string(), referer.to_string()), /// - f_str = 4 * |k - l| as a string
("User-Agent".to_string(), Self::FIREFOX_USER_AGENT.to_string()), /// - for g in 0..=j, h in 1..=4: n = d_raw[g+h] + f_str[g]; if n >= 10 then n -= 10
("Accept".to_string(), Self::HTML_ACCEPT.to_string()), fn generate_key(license_code: &str) -> Option<String> {
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()), let d_raw = license_code;
] // Strip '$' and replace '0' with '1'
} let d_mod: String = license_code
.get(1..)?
fn headers_with_cookies(
requester: &Requester,
request_url: &str,
referer: &str,
) -> Vec<(String, String)> {
let mut headers = Self::html_headers_with_referer(referer);
if let Some(cookie) = requester.cookie_header_for_url(request_url) {
headers.push(("Cookie".to_string(), cookie));
}
headers
}
async fn warm_root_session(requester: &mut Requester) {
let _ = requester
.get_with_headers(
Self::root_referer(),
Self::html_headers_with_referer(Self::root_referer()),
Some(Version::HTTP_11),
)
.await;
}
// ── Key generation (kt_player.js IIFE "function c", lines 541-586) ────────
//
// Derives the 32-char shuffle key from the page's license_code value.
//
// Steps:
// a) Build digit-string f from license_code[1:]; replace every 0 with 1.
// b) Split f at midpoint j = len/2; compute f_str = str(4 * |k l|).
// c) For g in 0..=j and h in 1..=4:
// n = digit(license_code[g+h]) + digit(f_str[g]);
// if n >= 10 { n -= 10 }
// push n onto key.
fn generate_key(license_code: &str) -> String {
let d: Vec<char> = license_code.chars().collect();
// (a) Build digit string, replacing 0 → 1
let mut f = String::new();
for ch in d.iter().skip(1) {
match ch.to_digit(10) {
Some(0) => f.push('1'),
Some(v) => f.push(char::from_digit(v, 10).unwrap()),
None => f.push('1'),
}
}
// (b) Split and compute intermediate
let j = f.len() / 2;
let k: i64 = f[..=j].parse().unwrap_or(0);
let l: i64 = f[j..].parse().unwrap_or(0);
let f_str = ((k - l).unsigned_abs() as i64 * 4).to_string();
// (c) Build 32-char key
let mut key = String::new();
for g in 0..=j {
for h in 1..=4usize {
let nd = d.get(g + h).and_then(|c| c.to_digit(10)).unwrap_or(0) as i64;
let nf = f_str.chars().nth(g).and_then(|c| c.to_digit(10)).unwrap_or(0) as i64;
let mut n = nd + nf;
if n >= 10 { n -= 10; }
key.push(char::from_digit(n as u32, 10).unwrap());
}
}
key
}
// ── Hash permutation (kt_player.js IIFE "function b", lines 521-539) ──────
//
// Reverse Fisher-Yates over the first 32 chars of the hash, driven by
// the cumulative digit-sum suffix of `key`.
//
// for k from 31 downto 0:
// l = k + Σ key_digit[m] for m in k..len(key)
// l %= 32
// swap hash[k] and hash[l]
fn transform_hash(hash_str: &str, key: &str) -> String {
let mut chars: Vec<char> = hash_str.chars().collect();
let tail_start = chars.len().min(32);
let (head, _) = chars.split_at(tail_start);
let mut h: Vec<char> = head.to_vec();
let tail: String = chars.drain(tail_start..).collect();
let key_digits: Vec<usize> = key
.chars() .chars()
.filter_map(|c| c.to_digit(10)) .map(|c| if c == '0' { '1' } else { c })
.map(|d| d as usize)
.collect(); .collect();
let len = h.len(); let j = d_mod.len() / 2;
for k in (0..len).rev() { let k: i64 = d_mod.get(..j + 1)?.parse().ok()?;
let mut l = k; let l: i64 = d_mod.get(j..)?.parse().ok()?;
for m in k..key_digits.len() { let f_str = (4 * (k - l).abs()).to_string();
l += key_digits[m];
}
l %= len;
h.swap(k, l);
}
let mut result: String = h.into_iter().collect(); let threshold = 10i64;
result.push_str(&tail); let d_bytes = d_raw.as_bytes();
result let f_bytes = f_str.as_bytes();
let mut key = String::with_capacity(32);
for g in 0..=j {
for h in 1..=4usize {
let d_digit = d_bytes.get(g + h).copied().unwrap_or(b'0') as i64 - b'0' as i64;
let f_digit = f_bytes.get(g).copied().unwrap_or(b'0') as i64 - b'0' as i64;
let mut n = d_digit + f_digit;
if n >= threshold {
n -= threshold;
}
key.push((b'0' + n as u8) as char);
}
}
Some(key)
} }
// ── URL decoder ──────────────────────────────────────────────────────────── /// Shuffle (permute) a 32-char hex hash using the key.
// ///
// Strips the `function/0/` prefix, un-shuffles the hash at path segment /// Stage 2: for k from 31 down to 0, compute l = (k + sum of key[k..]) % 32, swap h[k] and h[l].
// index 5, then returns the bare URL (without ?rnd append separately). fn shuffle_hash(hash: &str, key: &str) -> String {
fn decode_function0_url(input: &str, license_code: &str) -> Option<String> { let mut h: Vec<char> = hash.chars().collect();
if !input.starts_with("function/") { let n = h.len();
return Some(input.to_string()); let key_bytes = key.as_bytes();
for k in (0..n).rev() {
let mut l = k as i64;
for m in k..key_bytes.len() {
l += (key_bytes[m] - b'0') as i64;
}
l %= n as i64;
h.swap(k, l as usize);
} }
h.into_iter().collect()
}
// Strip "function/<N>/" /// Decode a KVS-encoded 'function/N/https://…' video URL into a usable URL.
let raw = input.splitn(3, '/').nth(2)?; ///
/// Stage 3: strip the function/N/ prefix, shuffle the 32-char hash in segment 6,
let mut parts: Vec<&str> = raw.split('/').collect(); /// then append ?rnd=<epoch_ms>.
fn decode_encoded_url(encoded: &str, license_code: &str) -> Option<String> {
// Expected segments after splitting on '/': let after_prefix = encoded.strip_prefix("function/")?;
// 0:"https:" 1:"" 2:"host" 3:"get_file" 4:N 5:HASH … // Split '0/https://host/path/hash_segment/…' into parts by '/'
if parts.len() < 6 { let parts: Vec<&str> = after_prefix.split('/').collect();
// Layout: [0]version [1]'https:' [2]'' [3]host [4]'get_file' [5]segment_no [6]hash_segment …
if parts.len() < 7 {
return None;
}
let hash_segment = parts[6];
if hash_segment.len() < 32 {
return None; return None;
} }
let key = Self::generate_key(license_code); let key = Self::generate_key(license_code)?;
let unscrambled = Self::transform_hash(parts[5], &key); let shuffled = Self::shuffle_hash(&hash_segment[..32], &key);
let new_hash_segment = format!("{}{}", shuffled, &hash_segment[32..]);
// Rebuild we need owned strings only for the one replaced segment let mut new_parts: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
let mut owned: Vec<String> = parts.iter().map(|s| s.to_string()).collect(); new_parts[6] = new_hash_segment;
owned[5] = unscrambled; new_parts.remove(0); // remove version number
Some(owned.join("/")) let rnd = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let url = new_parts.join("/");
Some(format!("{}?rnd={}", url, rnd))
} }
fn append_rnd(url: &str) -> String { /// Extract the license_code from the KVS player config in the page HTML.
let rnd = Self::js_now_millis(); /// The license_code property holds a 16-char string starting with '$'.
if url.contains("?rnd=") { fn extract_license_code(html: &str) -> Option<String> {
url.to_string() let re = Regex::new(r"license_code:\s*'(\$[0-9]{15})'").ok()?;
} else if url.contains('?') { Some(re.captures(html)?[1].to_string())
format!("{url}&rnd={rnd}") }
} else {
format!("{url}?rnd={rnd}") /// Extract all 'function/…' video URLs from the KVS player config in the page HTML.
/// Returns them in document order (lowest to highest quality for pimpbunny).
fn extract_video_urls(html: &str) -> Vec<String> {
let Ok(re) = Regex::new(r"video(?:_alt)?_url\d*:\s*'(function/[^']+)'") else {
return vec![];
};
re.captures_iter(html)
.map(|cap| cap[1].to_string())
.collect()
}
/// Returns true if the URL appears to serve video content (not an "access denied" response).
async fn url_is_accessible(url: &str, requester: &mut Requester) -> bool {
// Range: bytes=0-1 keeps the response body tiny while still probing auth.
match requester
.get_raw_with_headers(
url,
vec![("Range".to_string(), "bytes=0-1".to_string())],
)
.await
{
Ok(resp) => {
let s = resp.status().as_u16();
// 200 / 206 = success; 301/302/307 = redirect (follows, so we see final status)
// Treat anything that isn't a client-error 4xx as accessible.
s < 400 || s == 416 // 416 = Range Not Satisfiable means the server accepted auth
}
Err(_) => false,
} }
} }
// ── Page parser ──────────────────────────────────────────────────────────── /// Try to decode the video URL using the KVS algorithm extracted from kt_player.js.
// /// Returns the decoded URL if the server accepts it, None otherwise.
// 1. Extracts license_code: any config key whose name contains "code" async fn try_decode(detail_url: &str, requester: &mut Requester) -> Option<String> {
// (not at position 0) and whose value is exactly 16 chars matching let html = requester.get(detail_url, None).await.ok()?;
// the IIFE's own detection logic.
// 2. Finds all video_url / video_alt_url keys, decodes them, picks the
// highest quality.
fn extract_video_from_player_js(text: &str) -> Option<String> {
// Extract license_code from the player config.
// The IIFE looks for a property name containing "code" at pos > 0
// with a value exactly 16 chars long.
let license_code = Regex::new(
r#"[a-z_$][a-z0-9_$]*code[a-z0-9_$]*\s*:\s*'([^']{16})'"#,
)
.ok()
.and_then(|re| re.captures(text))
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| Self::LICENSE_CODE.to_string());
let pair_regex = Regex::new( let license_code = Self::extract_license_code(&html)?;
r#"(?P<key>video_url\d*|video_alt_url\d*(?:_text)?):\s*'(?P<value>[^']+)'"#, let encoded_urls = Self::extract_video_urls(&html);
) if encoded_urls.is_empty() {
.ok()?; return None;
let mut data: HashMap<String, String> = HashMap::new();
for cap in pair_regex.captures_iter(text) {
data.insert(cap["key"].to_string(), cap["value"].to_string());
} }
let mut sources: Vec<(u32, String)> = Vec::new(); // Attempt highest quality first (last URL in the config = highest non-redirect quality).
for encoded_url in encoded_urls.iter().rev() {
for (key, value) in &data { let Some(decoded) = Self::decode_encoded_url(encoded_url, &license_code) else {
if !(key.starts_with("video_url") || key.starts_with("video_alt_url")) {
continue; continue;
}
if key.ends_with("_text") {
continue;
}
let quality_key = format!("{key}_text");
let quality = data
.get(&quality_key)
.and_then(|v| v.replace('p', "").parse::<u32>().ok())
.unwrap_or(0);
let decoded = match Self::decode_function0_url(value, &license_code) {
Some(v) => v,
None => continue,
}; };
if Self::url_is_accessible(&decoded, requester).await {
sources.push((quality, Self::append_rnd(&decoded))); return Some(decoded);
}
} }
sources.sort_by(|a, b| b.0.cmp(&a.0)); None
sources.first().map(|(_, u)| u.clone())
}
// Fallback for standard JSON-LD if they ever use it again
fn extract_stream_url_from_html(text: &str) -> Option<String> {
Regex::new(r#""contentUrl"\s*:\s*"([^"]+)""#)
.ok()?
.captures(text)
.and_then(|captures| captures.get(1))
.map(|value| value.as_str().trim().to_string())
} }
} }
impl crate::proxies::Proxy for PimpbunnyProxy { impl crate::proxies::Proxy for PimpbunnyProxy {
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<crate::util::requester::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();
}; };
let mut requester = requester.get_ref().clone(); let mut req = requester.get_ref().clone();
Self::warm_root_session(&mut requester).await; if let Some(video_url) = Self::try_decode(&detail_url, &mut req).await {
return video_url;
}
let headers = Self::headers_with_cookies(&requester, &detail_url, &detail_url); // Fall back to browser-based extraction when the decoder fails or the decoded
// URL is rejected (access denied).
let text = match requester browser::wait_for_src(&detail_url, "//video[@src]")
.get_with_headers(&detail_url, headers, Some(Version::HTTP_2))
.await .await
{
Ok(text) => text,
Err(_) => return String::new(),
};
Self::extract_video_from_player_js(&text)
.or_else(|| Self::extract_stream_url_from_html(&text))
.unwrap_or_default() .unwrap_or_default()
} }
} }
@@ -307,87 +221,103 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_generate_key() { fn test_normalize_adds_https_scheme() {
// Deterministic: same license_code always produces the same 32-char key let url =
let key = PimpbunnyProxy::generate_key(PimpbunnyProxy::LICENSE_CODE); PimpbunnyProxy::normalize_detail_url("pimpbunny.com/videos/some-video/").unwrap();
assert_eq!(key.len(), 32, "key must be 32 digits"); assert!(url.starts_with("https://"));
assert!(key.chars().all(|c| c.is_ascii_digit()), "key must be all digits");
} }
#[test] #[test]
fn test_decode_function0_url() { fn test_normalize_passes_through_valid_https() {
// Example taken from live player config, verified against browser output let url = "https://pimpbunny.com/videos/some-video/";
let input = "function/0/https://pimpbunny.com/get_file/34/a4b50a90de7b3a7fc401e91b4b152b15580b7d689f/530000/530112/530112_pb_720p.mp4/"; assert_eq!(
let expected = "https://pimpbunny.com/get_file/34/b54c19bdbf5b7a3b4492111aa70e5e00580b7d689f/530000/530112/530112_pb_720p.mp4/"; PimpbunnyProxy::normalize_detail_url(url).unwrap(),
url
let decoded = PimpbunnyProxy::decode_function0_url(input, PimpbunnyProxy::LICENSE_CODE)
.expect("decode failed");
assert_eq!(decoded, expected);
}
#[test]
fn test_decode_canonical_example() {
// The original known-good example used to verify the algorithm
let input = "function/0/https://pimpbunny.com/get_file/40/7e4df9f504c7ec5b02b0101ed28edfa4c687186454/564000/564161/564161_720p.mp4/";
let expected = "https://pimpbunny.com/get_file/40/fde01fe04b42c7e72d0d0a8c95b1e45fc687186454/564000/564161/564161_720p.mp4/";
let decoded = PimpbunnyProxy::decode_function0_url(input, PimpbunnyProxy::LICENSE_CODE)
.expect("decode failed");
assert_eq!(decoded, expected);
}
#[test]
fn test_append_rnd() {
let url = "https://example.com/video.mp4";
let result = PimpbunnyProxy::append_rnd(url);
assert!(result.starts_with(url));
assert!(result.contains("?rnd="));
}
#[test]
fn test_append_rnd_existing_query() {
let url = "https://example.com/video.mp4?foo=bar";
let result = PimpbunnyProxy::append_rnd(url);
assert!(result.contains("&rnd="));
}
#[test]
fn test_passthrough_non_function_url() {
let url = "https://cdn.example.com/video.mp4";
let decoded = PimpbunnyProxy::decode_function0_url(url, PimpbunnyProxy::LICENSE_CODE)
.expect("passthrough failed");
assert_eq!(decoded, url);
}
#[test]
fn test_extract_highest_quality() {
let html = r#"
<script>
var t123456789abcdef = {
license_code: '$576262819011919',
video_url: 'function/0/https://pimpbunny.com/get_file/34/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/530000/530112/530112_pb_360p.mp4/',
video_url_text: '360p',
video_alt_url: 'function/0/https://pimpbunny.com/get_file/34/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/530000/530112/530112_pb_480p.mp4/',
video_alt_url_text: '480p',
video_alt_url2: 'function/0/https://pimpbunny.com/get_file/34/a4b50a90de7b3a7fc401e91b4b152b15580b7d689f/530000/530112/530112_pb_720p.mp4/',
video_alt_url2_text: '720p'
};
</script>
"#;
let result = PimpbunnyProxy::extract_video_from_player_js(html)
.expect("no url extracted");
assert!(result.contains("530112_pb_720p.mp4"), "did not choose highest quality");
assert!(
result.contains("b54c19bdbf5b7a3b4492111aa70e5e00580b7d689f"),
"did not decode scrambled token"
); );
assert!(result.contains("?rnd="), "missing rnd parameter"); }
#[test]
fn test_normalize_rejects_screenshots_path() {
let url =
"https://pimpbunny.com/contents/videos_screenshots/473000/473894/preview.jpg";
assert!(PimpbunnyProxy::normalize_detail_url(url).is_none());
}
#[test]
fn test_normalize_rejects_other_host() {
assert!(PimpbunnyProxy::normalize_detail_url("https://evil.com/videos/x/").is_none());
}
#[test]
fn test_normalize_rejects_http() {
assert!(PimpbunnyProxy::normalize_detail_url(
"http://pimpbunny.com/videos/some-video/"
)
.is_none());
}
#[test]
fn test_www_subdomain_is_allowed() {
let url = "https://www.pimpbunny.com/videos/some-video/";
assert!(PimpbunnyProxy::normalize_detail_url(url).is_some());
}
#[test]
fn test_generate_key() {
let key = PimpbunnyProxy::generate_key("$576262819011919").unwrap();
assert_eq!(key, "68732171060626281736958625345345");
assert_eq!(key.len(), 32);
}
#[test]
fn test_shuffle_hash() {
let key = "68732171060626281736958625345345";
let input = "7e4df9f504c7ec5b02b0101ed28edfa4";
let output = PimpbunnyProxy::shuffle_hash(input, key);
assert_eq!(output, "fde01fe04b42c7e72d0d0a8c95b1e45f");
}
#[test]
fn test_decode_encoded_url() {
let encoded = "function/0/https://pimpbunny.com/get_file/40/7e4df9f504c7ec5b02b0101ed28edfa4c687186454/564000/564161/564161_720p.mp4/";
let license = "$576262819011919";
let decoded = PimpbunnyProxy::decode_encoded_url(encoded, license).unwrap();
assert!(decoded.starts_with(
"https://pimpbunny.com/get_file/40/fde01fe04b42c7e72d0d0a8c95b1e45fc687186454/564000/564161/564161_720p.mp4/"
));
assert!(decoded.contains("?rnd="));
}
#[test]
fn test_extract_license_code() {
let html = r#"license_code: '$576262819011919', lrc: '77028724',"#;
assert_eq!(
PimpbunnyProxy::extract_license_code(html).unwrap(),
"$576262819011919"
);
}
#[test]
fn test_extract_video_urls() {
let html = r#"
video_url: 'function/0/https://pimpbunny.com/get_file/34/abc/530000/530112/530112_pb_360p.mp4/',
video_alt_url: 'function/0/https://pimpbunny.com/get_file/34/def/530000/530112/530112_pb_480p.mp4/',
video_alt_url2: 'function/0/https://pimpbunny.com/get_file/34/ghi/530000/530112/530112_pb_720p.mp4/',
video_alt_url5: 'https://pimpbunny.com/?upgrade=true',
"#;
let urls = PimpbunnyProxy::extract_video_urls(html);
assert_eq!(urls.len(), 3);
assert!(urls[0].starts_with("function/0/"));
assert!(urls[2].contains("720p"));
}
#[tokio::test]
async fn test_live_extract_charlotte_sins() {
let url = "https://pimpbunny.com/videos/charlotte-sins-gets-drilled-by-jax-slayher/";
let src = browser::wait_for_src(url, "//video[@src]").await;
println!("video src: {:?}", src);
assert!(src.is_some(), "expected a video URL but got None");
let src = src.unwrap();
assert!(src.starts_with("http"), "expected http URL, got: {src}");
} }
} }

111
src/proxies/thaiporntv.rs Normal file
View File

@@ -0,0 +1,111 @@
use ntex::web;
use crate::util::requester::Requester;
use crate::videos::VideoFormat;
use crate::providers::report_provider_error_background;
const CHANNEL_ID: &str = "thaiporntv";
const BASE_URL: &str = "https://www.thaiporntv.com";
#[derive(Debug, Clone)]
pub struct ThaipornTvProxy {}
impl ThaipornTvProxy {
pub fn new() -> Self {
ThaipornTvProxy {}
}
fn decode_data_enc(encoded_data: &str) -> Option<Vec<VideoFormat>> {
let cleaned_data = encoded_data.replace("-", "+").replace("_", "/");
let padded_data = format!("{:<pad$}", cleaned_data, pad = (cleaned_data.len() + 3) & !3);
let decoded_bytes = match base64::decode(&padded_data) {
Ok(bytes) => bytes,
Err(e) => {
report_provider_error_background(CHANNEL_ID, "proxy.decode_data_enc.base64", &format!("error={e}"));
return None;
}
};
if decoded_bytes.is_empty() {
report_provider_error_background(CHANNEL_ID, "proxy.decode_data_enc.empty", "decoded bytes are empty");
return None;
}
let key = decoded_bytes[0];
let decrypted_bytes: Vec<u8> = decoded_bytes[1..]
.iter()
.map(|&b| b ^ key)
.collect();
let json_str = match String::from_utf8(decrypted_bytes) {
Ok(s) => s,
Err(e) => {
report_provider_error_background(CHANNEL_ID, "proxy.decode_data_enc.utf8", &format!("error={e}"));
return None;
}
};
let formats_json: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(value) => value,
Err(e) => {
report_provider_error_background(CHANNEL_ID, "proxy.decode_data_enc.json_parse", &format!("error={e}"));
return None;
}
};
let Some(formats_array) = formats_json.as_array() else {
report_provider_error_background(CHANNEL_ID, "proxy.decode_data_enc.not_array", "JSON is not an array");
return None;
};
let mut formats = Vec::new();
for format_val in formats_array {
let Some(u) = format_val.get("u").and_then(|v| v.as_str()) else { continue; };
let Some(q) = format_val.get("q").and_then(|v| v.as_str()) else { continue; };
let mut format = VideoFormat::new(
u.to_string(),
q.to_string(),
"application/x-mpegURL".to_string(),
);
// Add referer to the format
format.add_http_header("Referer".to_string(), BASE_URL.to_string());
formats.push(format);
}
Some(formats)
}
pub async fn get_video_url(
&self,
url_path: String, // Expects something like videos/2019/thai-student-have-sex-at-taxi-1658/
requester: web::types::State<Requester>,
) -> String {
let mut requester = requester.get_ref().clone();
let video_detail_url = format!("{}/{}", BASE_URL, url_path.trim_start_matches('/'));
let html = match requester.get(&video_detail_url, None).await {
Ok(text) => text,
Err(e) => {
report_provider_error_background(CHANNEL_ID, "proxy.get_video_url.fetch_html", &format!("url={}; error={}", video_detail_url, e));
return String::new();
}
};
let document = scraper::Html::parse_document(&html);
let video_element_selector = scraper::Selector::parse("video[data-enc]").unwrap();
if let Some(video_element) = document.select(&video_element_selector).next() {
if let Some(data_enc) = video_element.value().attr("data-enc") {
if let Some(formats) = Self::decode_data_enc(data_enc) {
// Return the URL of the best quality format, or the first one if quality is not a factor.
// For m3u8, we typically just return the m3u8 playlist URL.
if let Some(format) = formats.first() {
return format.url.clone();
}
}
}
}
String::new()
}
}

View File

@@ -15,6 +15,7 @@ use crate::proxies::sxyprn::SxyprnProxy;
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;
use crate::proxies::thaiporntv::ThaipornTvProxy;
use crate::proxies::*; use crate::proxies::*;
use crate::util::requester::Requester; use crate::util::requester::Requester;
@@ -125,11 +126,16 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route(web::get().to(crate::proxies::porndishthumb::get_image)), .route(web::get().to(crate::proxies::porndishthumb::get_image)),
); );
cfg.service( cfg.service(
web::resource("/pornhub-thumb/{endpoint}*") web::resource("/proxy/pornhub-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::pornhubthumb::get_image)) .route(web::post().to(crate::proxies::pornhubthumb::get_image))
.route(web::get().to(crate::proxies::pornhubthumb::get_image)), .route(web::get().to(crate::proxies::pornhubthumb::get_image)),
); );
} cfg.service(
web::resource("/proxy/thaiporntv/{tail:.*}")
.route(web::post().to(proxy2redirect))
.route(web::get().to(proxy2redirect)),
);
}
async fn proxy2redirect( async fn proxy2redirect(
req: HttpRequest, req: HttpRequest,
@@ -163,6 +169,7 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
"porndish" => Some(AnyProxy::Porndish(PorndishProxy::new())), "porndish" => Some(AnyProxy::Porndish(PorndishProxy::new())),
"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())),
_ => None, _ => None,
} }
} }

41
src/util/browser.rs Normal file
View File

@@ -0,0 +1,41 @@
use crate::util::{playwright, webdriver};
use std::time::Duration;
use tokio::sync::mpsc;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// Races chromiumoxide (webdriver) and Playwright against each other.
/// Navigates to `url` and returns the `src` attribute of the first element
/// matched by `xpath` — whichever backend resolves it first wins.
pub async fn wait_for_src(url: &str, xpath: &str) -> Option<String> {
wait_for_attribute(url, xpath, "src", DEFAULT_TIMEOUT).await
}
/// Like [`wait_for_src`] but lets the caller choose the attribute name and timeout.
pub async fn wait_for_attribute(
url: &str,
xpath: &str,
attribute: &str,
wait_timeout: Duration,
) -> Option<String> {
// Buffer of 1: the first send wins; the losing task's send is silently
// dropped when the receiver is gone.
let (tx, mut rx) = mpsc::channel::<String>(1);
let (u, x, a) = (url.to_string(), xpath.to_string(), attribute.to_string());
let tx1 = tx.clone();
tokio::spawn(async move {
if let Some(src) = webdriver::wait_for_attribute(&u, &x, &a, wait_timeout).await {
let _ = tx1.send(src).await;
}
});
let (u, x, a) = (url.to_string(), xpath.to_string(), attribute.to_string());
tokio::spawn(async move {
if let Some(src) = playwright::wait_for_attribute(&u, &x, &a, wait_timeout).await {
let _ = tx.send(src).await;
}
});
rx.recv().await
}

View File

@@ -9,7 +9,10 @@ pub mod hoster_proxy;
pub mod proxy; pub mod proxy;
pub mod requester; pub mod requester;
pub mod time; pub mod time;
pub mod browser;
pub mod dean_edwards; pub mod dean_edwards;
pub mod playwright;
pub mod webdriver;
pub fn parse_abbreviated_number(s: &str) -> Option<u32> { pub fn parse_abbreviated_number(s: &str) -> Option<u32> {
let s = s.trim(); let s = s.trim();

128
src/util/playwright.rs Normal file
View File

@@ -0,0 +1,128 @@
use playwright::Playwright;
use playwright::api::Page;
use std::path::Path;
use std::time::Duration;
use tokio::time::{sleep, timeout};
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const CHROME_ARGS: &[&str] = &[
"--no-sandbox",
"--disable-dev-shm-usage",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-ipc-flooding-protection",
"--disable-hang-monitor",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-accelerated-2d-canvas",
"--disable-webgl",
"--disable-3d-apis",
"--blink-settings=imagesEnabled=false",
"--mute-audio",
"--disable-background-networking",
"--disable-client-side-phishing-detection",
"--disable-component-update",
"--disable-domain-reliability",
"--disable-sync",
"--metrics-recording-only",
"--safebrowsing-disable-auto-update",
"--disable-extensions",
"--disable-translate",
"--disable-default-apps",
"--disable-features=site-per-process",
"--password-store=basic",
"--use-mock-keychain",
];
/// Launches a headless Chrome browser via Playwright, navigates to `url`, and
/// polls until the element matched by `xpath` has a non-empty `src` attribute.
pub async fn wait_for_src(url: &str, xpath: &str) -> Option<String> {
wait_for_attribute(url, xpath, "src", DEFAULT_TIMEOUT).await
}
/// Like [`wait_for_src`] but lets the caller choose the attribute name and timeout.
pub async fn wait_for_attribute(
url: &str,
xpath: &str,
attribute: &str,
wait_timeout: Duration,
) -> Option<String> {
// _pw must be kept alive — dropping it kills the driver process.
let (_pw, page) = open_page(url).await?;
let xpath_js = serde_json::to_string(xpath).unwrap_or_default();
let attr_js = serde_json::to_string(attribute).unwrap_or_default();
let js = format!(
r#"() => {{
var r = document.evaluate({xpath_js}, document, null,
XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var el = r.singleNodeValue;
if (!el) return '';
return el.getAttribute({attr_js}) || el[{attr_js}] || '';
}}"#
);
let result = timeout(wait_timeout, async {
loop {
match page.evaluate::<(), String>(&js, ()).await {
Ok(s) if !s.is_empty() => return Some(s),
Ok(_) => {}
Err(e) => eprintln!("[playwright] evaluate error: {e}"),
}
sleep(POLL_INTERVAL).await;
}
})
.await
.ok()
.flatten();
result
}
// ── internals ─────────────────────────────────────────────────────────────────
async fn open_page(url: &str) -> Option<(Playwright, Page)> {
let pw = Playwright::initialize()
.await
.map_err(|e| eprintln!("[playwright] init error: {e}"))
.ok()?;
let args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
let browser = pw
.chromium()
.launcher()
.executable(Path::new("/usr/bin/google-chrome"))
.headless(true)
.args(&args)
.launch()
.await
.map_err(|e| eprintln!("[playwright] launch error: {e}"))
.ok()?;
let context = browser
.context_builder()
.build()
.await
.map_err(|e| eprintln!("[playwright] context error: {e}"))
.ok()?;
let page = context
.new_page()
.await
.map_err(|e| eprintln!("[playwright] new_page error: {e}"))
.ok()?;
page.goto_builder(url)
.goto()
.await
.map_err(|e| eprintln!("[playwright] goto error: {e}"))
.ok()?;
Some((pw, page))
}

130
src/util/webdriver.rs Normal file
View File

@@ -0,0 +1,130 @@
use chromiumoxide::{Browser, BrowserConfig};
use futures::StreamExt;
use std::time::Duration;
use tokio::time::{sleep, timeout};
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// Launches a headless Chrome browser, navigates to `url`, and polls until the
/// element matched by `xpath` has a non-empty `src` attribute, then returns it.
///
/// Equivalent to Selenium's:
/// `WebDriverWait(driver, 30).until(lambda d: d.find_element(By.XPATH, xpath).get_attribute("src"))`
pub async fn wait_for_src(url: &str, xpath: &str) -> Option<String> {
wait_for_attribute(url, xpath, "src", DEFAULT_TIMEOUT).await
}
/// Like [`wait_for_src`] but lets the caller choose the attribute name and timeout.
pub async fn wait_for_attribute(
url: &str,
xpath: &str,
attribute: &str,
wait_timeout: Duration,
) -> Option<String> {
let (mut browser, page) = open_page(url).await?;
// Embed xpath and attribute as JSON strings so they are safely quoted inside
// the JS template — avoids any injection from untrusted caller values.
let xpath_js = serde_json::to_string(xpath).unwrap_or_default();
let attr_js = serde_json::to_string(attribute).unwrap_or_default();
let js = format!(
r#"(function() {{
var r = document.evaluate({xpath_js}, document, null,
XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var el = r.singleNodeValue;
if (!el) return '';
return el.getAttribute({attr_js}) || el[{attr_js}] || '';
}})()"#
);
let result = timeout(wait_timeout, async {
loop {
match page.evaluate(js.as_str()).await {
Ok(val) => {
if let Ok(s) = val.into_value::<String>() {
if !s.is_empty() {
return Some(s);
}
}
}
Err(e) => eprintln!("[webdriver] evaluate error: {e}"),
}
sleep(POLL_INTERVAL).await;
}
})
.await
.ok()
.flatten();
let _ = browser.close().await;
result
}
// ── internals ─────────────────────────────────────────────────────────────────
async fn open_page(
url: &str,
) -> Option<(Browser, chromiumoxide::Page)> {
let config = BrowserConfig::builder()
// ── headless & sandbox ────────────────────────────────────────
.arg("--headless=new")
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage")
.arg("--no-first-run")
.arg("--no-default-browser-check")
// ── JS timer / scheduler: prevent Chrome from throttling
// background timers and renderers (critical for player init) ──
.arg("--disable-background-timer-throttling")
.arg("--disable-backgrounding-occluded-windows")
.arg("--disable-renderer-backgrounding")
.arg("--disable-ipc-flooding-protection")
.arg("--disable-hang-monitor")
// ── skip rendering work we don't need ────────────────────────
.arg("--disable-gpu")
.arg("--disable-software-rasterizer")
.arg("--disable-accelerated-2d-canvas")
.arg("--disable-webgl")
.arg("--disable-3d-apis")
.arg("--blink-settings=imagesEnabled=false")
.arg("--mute-audio")
// ── cut background network / update noise ────────────────────
.arg("--disable-background-networking")
.arg("--disable-client-side-phishing-detection")
.arg("--disable-component-update")
.arg("--disable-domain-reliability")
.arg("--disable-sync")
.arg("--metrics-recording-only")
.arg("--safebrowsing-disable-auto-update")
// ── misc overhead ─────────────────────────────────────────────
.arg("--disable-extensions")
.arg("--disable-translate")
.arg("--disable-default-apps")
.arg("--disable-features=site-per-process")
.arg("--password-store=basic")
.arg("--use-mock-keychain")
.build()
.map_err(|e| eprintln!("[webdriver] BrowserConfig error: {e}"))
.ok()?;
let (browser, mut handler) = Browser::launch(config)
.await
.map_err(|e| eprintln!("[webdriver] Browser launch error: {e}"))
.ok()?;
tokio::spawn(async move {
loop {
if handler.next().await.is_none() {
break;
}
}
});
let page = browser
.new_page(url)
.await
.map_err(|e| eprintln!("[webdriver] new_page error: {e}"))
.ok()?;
Some((browser, page))
}