This commit is contained in:
Simon
2026-03-16 20:31:58 +00:00
parent f8a09b0e97
commit 2d4e456c61
8 changed files with 2280 additions and 293 deletions

View File

@@ -83,6 +83,40 @@ impl Ord for ClientVersion {
} }
} }
fn client_version_from_request(req: &HttpRequest) -> ClientVersion {
match req.headers().get("User-Agent") {
Some(v) => match v.to_str() {
Ok(useragent) => ClientVersion::parse(useragent)
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
},
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
}
}
async fn ensure_videos_table(pool: &DbPool) {
match pool.get() {
Ok(mut conn) => match db::has_table(&mut conn, "videos") {
Ok(false) => {
if let Err(e) = db::create_table(
&mut conn,
"CREATE TABLE videos (id TEXT NOT NULL, url TEXT NOT NULL);",
) {
report_provider_error("db", "ensure_videos_table.create_table", &e.to_string())
.await;
}
}
Ok(true) => {}
Err(e) => {
report_provider_error("db", "ensure_videos_table.has_table", &e.to_string()).await;
}
},
Err(e) => {
report_provider_error("db", "ensure_videos_table.pool_get", &e.to_string()).await;
}
}
}
fn normalize_query(raw_query: Option<&str>) -> (Option<String>, Option<String>) { fn normalize_query(raw_query: Option<&str>) -> (Option<String>, Option<String>) {
let Some(raw_query) = raw_query else { let Some(raw_query) = raw_query else {
return (None, None); return (None, None);
@@ -130,6 +164,55 @@ fn video_matches_literal_query(video: &VideoItem, literal_query: &str) -> bool {
.is_some_and(|tags| tags.iter().any(|tag| contains_literal(tag))) .is_some_and(|tags| tags.iter().any(|tag| contains_literal(tag)))
} }
fn normalize_uploader_name(value: &str) -> String {
value
.trim()
.trim_start_matches('#')
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn video_matches_normalized_uploader(video: &VideoItem, normalized_uploader: &str) -> bool {
video
.uploader
.as_deref()
.map(normalize_uploader_name)
.is_some_and(|value| value == normalized_uploader)
}
fn add_inline_previews(video_items: &mut [VideoItem]) {
for video in video_items.iter_mut() {
if video.duration <= 120 {
let mut preview_url = video.url.clone();
if let Some(x) = &video.formats {
if let Some(first) = x.first() {
preview_url = first.url.clone();
}
}
video.preview = Some(preview_url);
}
}
}
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in normalize_uploader_name(value).chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
slug.trim_matches('-').to_string()
}
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::resource("/status") web::resource("/status")
@@ -141,19 +224,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
// .route(web::get().to(videos_get)) // .route(web::get().to(videos_get))
.route(web::post().to(videos_post)), .route(web::post().to(videos_post)),
) )
.service(web::resource("/uploader").route(web::post().to(uploader_post)))
.service(web::resource("/uploaders").route(web::post().to(uploader_post)))
.service(web::resource("/test").route(web::get().to(test))) .service(web::resource("/test").route(web::get().to(test)))
.service(web::resource("/proxies").route(web::get().to(proxies))); .service(web::resource("/proxies").route(web::get().to(proxies)));
} }
async fn status(req: HttpRequest) -> Result<impl web::Responder, web::Error> { async fn status(req: HttpRequest) -> Result<impl web::Responder, web::Error> {
let clientversion: ClientVersion = match req.headers().get("User-Agent") { let clientversion = client_version_from_request(&req);
Some(v) => match v.to_str() {
Ok(useragent) => ClientVersion::parse(useragent)
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
},
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
};
println!( println!(
"Received status request with client version: {:?}", "Received status request with client version: {:?}",
@@ -198,35 +276,9 @@ async fn videos_post(
requester: web::types::State<Requester>, requester: web::types::State<Requester>,
req: HttpRequest, req: HttpRequest,
) -> Result<impl web::Responder, web::Error> { ) -> Result<impl web::Responder, web::Error> {
let clientversion: ClientVersion = match req.headers().get("User-Agent") { let clientversion = client_version_from_request(&req);
Some(v) => match v.to_str() {
Ok(useragent) => ClientVersion::parse(useragent)
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
},
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
};
let requester = requester.get_ref().clone(); let requester = requester.get_ref().clone();
// Ensure "videos" table exists with two string columns. ensure_videos_table(pool.get_ref()).await;
match pool.get() {
Ok(mut conn) => match db::has_table(&mut conn, "videos") {
Ok(false) => {
if let Err(e) = db::create_table(
&mut conn,
"CREATE TABLE videos (id TEXT NOT NULL, url TEXT NOT NULL);",
) {
report_provider_error("db", "videos_post.create_table", &e.to_string()).await;
}
}
Ok(true) => {}
Err(e) => {
report_provider_error("db", "videos_post.has_table", &e.to_string()).await;
}
},
Err(e) => {
report_provider_error("db", "videos_post.pool_get", &e.to_string()).await;
}
}
let mut videos = Videos { let mut videos = Videos {
pageInfo: PageInfo { pageInfo: PageInfo {
@@ -387,21 +439,182 @@ async fn videos_post(
}); });
//### //###
for video in videos.items.iter_mut() { add_inline_previews(&mut videos.items);
if video.duration <= 120 {
let mut preview_url = video.url.clone();
if let Some(x) = &video.formats {
if let Some(first) = x.first() {
preview_url = first.url.clone();
}
}
video.preview = Some(preview_url);
}
}
Ok(web::HttpResponse::Ok().json(&videos)) Ok(web::HttpResponse::Ok().json(&videos))
} }
async fn uploader_post(
uploader_request: web::types::Json<UploaderRequest>,
cache: web::types::State<VideoCache>,
pool: web::types::State<DbPool>,
requester: web::types::State<Requester>,
req: HttpRequest,
) -> Result<impl web::Responder, web::Error> {
let clientversion = client_version_from_request(&req);
let requester = requester.get_ref().clone();
ensure_videos_table(pool.get_ref()).await;
let uploader_name = uploader_request
.uploader
.as_deref()
.or(uploader_request.title.as_deref())
.or(uploader_request.query.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| web::error::ErrorBadRequest("Missing uploader".to_string()))?;
let normalized_uploader = normalize_uploader_name(uploader_name);
if normalized_uploader.is_empty() {
return Err(web::error::ErrorBadRequest("Missing uploader".to_string()).into());
}
let page: u8 = uploader_request
.page
.as_ref()
.and_then(|value| value.to_u8())
.unwrap_or(1);
let per_page: u8 = uploader_request
.perPage
.as_ref()
.and_then(|value| value.to_u8())
.unwrap_or(10);
let sort = uploader_request
.sort
.as_deref()
.unwrap_or("date")
.to_string();
let featured = uploader_request
.featured
.as_deref()
.unwrap_or("all")
.to_string();
let category = uploader_request
.category
.as_deref()
.unwrap_or("all")
.to_string();
let sites = uploader_request
.all_provider_sites
.as_deref()
.or(uploader_request.sites.as_deref())
.unwrap_or("")
.to_string();
let filter = uploader_request
.filter
.as_deref()
.unwrap_or("new")
.to_string();
let language = uploader_request
.language
.as_deref()
.unwrap_or("en")
.to_string();
let networks = uploader_request
.networks
.as_deref()
.unwrap_or("")
.to_string();
let stars = uploader_request.stars.as_deref().unwrap_or("").to_string();
let categories = uploader_request
.categories
.as_deref()
.unwrap_or("")
.to_string();
let duration = uploader_request
.duration
.as_deref()
.unwrap_or("")
.to_string();
let sexuality = uploader_request
.sexuality
.as_deref()
.unwrap_or("")
.to_string();
let public_url_base = format!(
"{}://{}",
req.connection_info().scheme(),
req.connection_info().host()
);
let options = ServerOptions {
featured: Some(featured),
category: Some(category),
sites: Some(sites),
filter: Some(filter),
language: Some(language),
public_url_base: Some(public_url_base),
requester: Some(requester),
network: Some(networks),
stars: Some(stars),
categories: Some(categories),
duration: Some(duration),
sort: Some(sort.clone()),
sexuality: Some(sexuality),
};
let provider = get_provider("all")
.ok_or_else(|| web::error::ErrorBadRequest("Invalid channel".to_string()))?;
let mut video_items = run_provider_guarded(
"all",
"uploader_post.get_videos",
provider.get_videos(
cache.get_ref().clone(),
pool.get_ref().clone(),
sort,
Some(uploader_name.to_string()),
page.to_string(),
per_page.to_string(),
options,
),
)
.await;
if clientversion == ClientVersion::new(38, 0, "Hot%20Tub".to_string()) {
video_items = video_items
.into_iter()
.filter_map(|video| {
let last_url = video
.formats
.as_ref()
.and_then(|formats| formats.last().map(|f| f.url.clone()));
if let Some(url) = last_url {
let mut v = video;
v.url = url;
return Some(v);
}
Some(video)
})
.collect();
}
video_items.retain(|video| video_matches_normalized_uploader(video, &normalized_uploader));
add_inline_previews(&mut video_items);
let display_name = video_items
.iter()
.find_map(|video| video.uploader.as_ref())
.cloned()
.unwrap_or_else(|| uploader_name.to_string());
let row = LayoutRow {
id: "videos".to_string(),
row_type: "videos".to_string(),
title: "Videos".to_string(),
subtitle: Some(format!("Results for {display_name}")),
pageInfo: PageInfo {
hasNextPage: !video_items.is_empty(),
resultsPerPage: u32::from(per_page),
},
items: video_items,
};
let response = UploaderResponse {
id: slugify(&display_name),
title: display_name.clone(),
uploader: display_name,
rows: vec![row],
};
Ok(web::HttpResponse::Ok().json(&response))
}
pub fn get_provider(channel: &str) -> Option<DynProvider> { pub fn get_provider(channel: &str) -> Option<DynProvider> {
ALL_PROVIDERS.get(channel).cloned() ALL_PROVIDERS.get(channel).cloned()
} }
@@ -443,3 +656,41 @@ pub async fn proxies() -> Result<impl web::Responder, web::Error> {
} }
Ok(web::HttpResponse::Ok().json(&by_protocol)) Ok(web::HttpResponse::Ok().json(&by_protocol))
} }
#[cfg(test)]
mod tests {
use super::{normalize_uploader_name, slugify, video_matches_normalized_uploader};
use crate::videos::VideoItem;
#[test]
fn normalize_uploader_name_collapses_spacing_and_case() {
assert_eq!(
normalize_uploader_name(" #The Pet Collective "),
"the pet collective"
);
}
#[test]
fn uploader_match_uses_normalized_equality() {
let video = VideoItem::new(
"id".to_string(),
"title".to_string(),
"https://example.com/video".to_string(),
"all".to_string(),
"https://example.com/thumb.jpg".to_string(),
90,
)
.uploader("The Pet Collective".to_string());
assert!(video_matches_normalized_uploader(
&video,
"the pet collective"
));
assert!(!video_matches_normalized_uploader(&video, "pet collective"));
}
#[test]
fn slugify_uses_normalized_name() {
assert_eq!(slugify(" #The Pet Collective "), "the-pet-collective");
}
}

View File

@@ -25,7 +25,6 @@ pub mod pmvhaven;
pub mod pornhat; pub mod pornhat;
pub mod pornhub; pub mod pornhub;
pub mod redtube; pub mod redtube;
pub mod rule34video;
pub mod spankbang; pub mod spankbang;
// pub mod hentaimoon; // pub mod hentaimoon;
pub mod beeg; pub mod beeg;
@@ -34,6 +33,7 @@ pub mod omgxxx;
pub mod paradisehill; pub mod paradisehill;
pub mod porn00; pub mod porn00;
pub mod porn4fans; pub mod porn4fans;
pub mod porndish;
pub mod pornzog; pub mod pornzog;
pub mod shooshtime; pub mod shooshtime;
pub mod sxyprn; pub mod sxyprn;
@@ -53,6 +53,7 @@ pub mod javtiful;
pub mod noodlemagazine; pub mod noodlemagazine;
pub mod pimpbunny; pub mod pimpbunny;
pub mod rule34gen; pub mod rule34gen;
pub mod rule34video;
pub mod xxdbx; pub mod xxdbx;
// pub mod tube8; // pub mod tube8;
@@ -78,10 +79,6 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
"spankbang", "spankbang",
Arc::new(spankbang::SpankbangProvider::new()) as DynProvider, Arc::new(spankbang::SpankbangProvider::new()) as DynProvider,
); );
m.insert(
"rule34video",
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
);
m.insert( m.insert(
"redtube", "redtube",
Arc::new(redtube::RedtubeProvider::new()) as DynProvider, Arc::new(redtube::RedtubeProvider::new()) as DynProvider,
@@ -134,6 +131,10 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
"porn4fans", "porn4fans",
Arc::new(porn4fans::Porn4fansProvider::new()) as DynProvider, Arc::new(porn4fans::Porn4fansProvider::new()) as DynProvider,
); );
m.insert(
"porndish",
Arc::new(porndish::PorndishProvider::new()) as DynProvider,
);
m.insert( m.insert(
"shooshtime", "shooshtime",
Arc::new(shooshtime::ShooshtimeProvider::new()) as DynProvider, Arc::new(shooshtime::ShooshtimeProvider::new()) as DynProvider,
@@ -160,6 +161,10 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
Arc::new(viralxxxporn::ViralxxxpornProvider::new()) as DynProvider, Arc::new(viralxxxporn::ViralxxxpornProvider::new()) as DynProvider,
); );
// m.insert("pornxp", Arc::new(pornxp::PornxpProvider::new()) as DynProvider); // m.insert("pornxp", Arc::new(pornxp::PornxpProvider::new()) as DynProvider);
m.insert(
"rule34video",
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
);
m.insert( m.insert(
"rule34gen", "rule34gen",
Arc::new(rule34gen::Rule34genProvider::new()) as DynProvider, Arc::new(rule34gen::Rule34genProvider::new()) as DynProvider,

1066
src/providers/porndish.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ pub mod hanimecdn;
pub mod hqpornerthumb; pub mod hqpornerthumb;
pub mod javtiful; pub mod javtiful;
pub mod noodlemagazine; pub mod noodlemagazine;
pub mod porndishthumb;
pub mod spankbang; pub mod spankbang;
pub mod sxyprn; pub mod sxyprn;

View File

@@ -0,0 +1,62 @@
use ntex::http::header::CONTENT_TYPE;
use ntex::{
http::Response,
web::{self, HttpRequest, error},
};
use std::process::Command;
use crate::util::requester::Requester;
pub async fn get_image(
req: HttpRequest,
_requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").to_string();
let image_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint
} else {
format!("https://{}", endpoint.trim_start_matches('/'))
};
let output = tokio::task::spawn_blocking(move || {
Command::new("python3")
.arg("-c")
.arg(
r#"
import sys
from curl_cffi import requests
url = sys.argv[1]
response = requests.get(
url,
impersonate="chrome",
timeout=30,
allow_redirects=True,
headers={"Referer": "https://www.porndish.com/"},
)
if response.status_code >= 400:
sys.stderr.write(f"status={response.status_code}\n")
sys.exit(1)
sys.stderr.write(response.headers.get("content-type", "application/octet-stream"))
sys.stdout.buffer.write(response.content)
"#,
)
.arg(image_url)
.output()
})
.await
.map_err(error::ErrorBadGateway)?
.map_err(error::ErrorBadGateway)?;
if !output.status.success() {
return Ok(web::HttpResponse::NotFound().finish());
}
let content_type = String::from_utf8_lossy(&output.stderr).trim().to_string();
let mut resp = Response::build(ntex::http::StatusCode::OK);
if !content_type.is_empty() {
resp.set_header(CONTENT_TYPE, content_type);
}
Ok(resp.body(output.stdout))
}

View File

@@ -36,6 +36,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::resource("/hqporner-thumb/{endpoint}*") web::resource("/hqporner-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::hqpornerthumb::get_image)) .route(web::post().to(crate::proxies::hqpornerthumb::get_image))
.route(web::get().to(crate::proxies::hqpornerthumb::get_image)), .route(web::get().to(crate::proxies::hqpornerthumb::get_image)),
)
.service(
web::resource("/porndish-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::porndishthumb::get_image))
.route(web::get().to(crate::proxies::porndishthumb::get_image)),
); );
} }

View File

@@ -53,6 +53,30 @@ pub struct VideosRequest {
pub duration: Option<String>, pub duration: Option<String>,
} }
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct UploaderRequest {
pub uploader: Option<String>,
pub title: Option<String>,
pub uploaderUrl: Option<String>,
pub uploaderId: Option<String>,
pub channel: Option<String>,
pub sort: Option<String>,
pub query: Option<String>,
pub page: Option<FlexibleNumber>,
pub perPage: Option<FlexibleNumber>,
pub featured: Option<String>,
pub category: Option<String>,
pub sites: Option<String>,
pub all_provider_sites: Option<String>,
pub filter: Option<String>,
pub language: Option<String>,
pub networks: Option<String>,
pub stars: Option<String>,
pub categories: Option<String>,
pub duration: Option<String>,
pub sexuality: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ServerOptions { pub struct ServerOptions {
pub featured: Option<String>, // "featured", pub featured: Option<String>, // "featured",
@@ -405,3 +429,23 @@ pub struct Videos {
pub pageInfo: PageInfo, pub pageInfo: PageInfo,
pub items: Vec<VideoItem>, pub items: Vec<VideoItem>,
} }
#[derive(serde::Serialize, Debug)]
pub struct LayoutRow {
pub id: String,
#[serde(rename = "type")]
pub row_type: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
pub pageInfo: PageInfo,
pub items: Vec<VideoItem>,
}
#[derive(serde::Serialize, Debug)]
pub struct UploaderResponse {
pub id: String,
pub title: String,
pub uploader: String,
pub rows: Vec<LayoutRow>,
}