use crate::providers::{ ALL_PROVIDERS, DynProvider, panic_payload_to_string, report_provider_error, run_provider_guarded, }; use crate::util::cache::VideoCache; use crate::util::discord::send_discord_error_report; use crate::util::proxy::{Proxy, all_proxies_snapshot}; use crate::util::requester::Requester; use crate::{DbPool, db, status::*, videos::*}; use ntex::http::header; use ntex::web; use ntex::web::HttpRequest; use std::cmp::Ordering; use std::io; use tokio::task; #[derive(Debug, Clone)] pub struct ClientVersion { version: u32, subversion: u32, name: String, } impl ClientVersion { pub fn new(version: u32, subversion: u32, name: String) -> ClientVersion { ClientVersion { version, subversion, name, } } pub fn parse(input: &str) -> Option { // Example input: "Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0 0.002478" let first_part = input.split_whitespace().next()?; let mut name_version = first_part.splitn(2, '/'); let name = name_version.next()?; let version_str = name_version.next()?; // Find the index where the numeric part ends let split_idx = version_str .find(|c: char| !c.is_ascii_digit()) .unwrap_or(version_str.len()); let (v_num, v_alpha) = version_str.split_at(split_idx); // Parse the numeric version let version = v_num.parse::().ok()?; // Convert the first character of the subversion to u32 (ASCII value), // or 0 if it doesn't exist. let subversion = v_alpha.chars().next().map(|ch| ch as u32).unwrap_or(0); Some(Self { version, subversion, name: name.to_string(), }) } } // Implement comparisons impl PartialEq for ClientVersion { fn eq(&self, other: &Self) -> bool { self.name == other.name } } impl Eq for ClientVersion {} impl PartialOrd for ClientVersion { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for ClientVersion { fn cmp(&self, other: &Self) -> Ordering { self.version .cmp(&other.version) .then_with(|| self.subversion.cmp(&other.subversion)) } } pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("/status") .route(web::post().to(status)) .route(web::get().to(status)), ) .service( web::resource("/videos") // .route(web::get().to(videos_get)) .route(web::post().to(videos_post)), ) .service(web::resource("/test").route(web::get().to(test))) .service(web::resource("/proxies").route(web::get().to(proxies))); } async fn status(req: HttpRequest) -> Result { let clientversion: 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()), }; println!( "Received status request with client version: {:?}", clientversion ); let host = req .headers() .get(header::HOST) .and_then(|h| h.to_str().ok()) .unwrap_or_default() .to_string(); let public_url_base = format!("{}://{}", req.connection_info().scheme(), host); let mut status = Status::new(); for (provider_name, provider) in ALL_PROVIDERS.iter() { let channel_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { provider.get_channel(clientversion.clone()) })); match channel_result { Ok(Some(mut channel)) => { if channel.favicon.starts_with('/') { channel.favicon = format!("{}{}", public_url_base, channel.favicon); } status.add_channel(channel) } Ok(None) => {} Err(payload) => { let panic_msg = panic_payload_to_string(payload); report_provider_error(provider_name, "status.get_channel", &panic_msg).await; } } } status.iconUrl = format!("{}/favicon.ico", public_url_base).to_string(); Ok(web::HttpResponse::Ok().json(&status)) } async fn videos_post( mut video_request: web::types::Json, cache: web::types::State, pool: web::types::State, requester: web::types::State, req: HttpRequest, ) -> Result { let clientversion: 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()), }; match video_request.query.as_deref() { Some(query) if query.starts_with("#") => { video_request.query = Some(query.trim_start_matches("#").to_string()); } _ => {} } let requester = requester.get_ref().clone(); // Ensure "videos" table exists with two string columns. 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 { pageInfo: PageInfo { hasNextPage: true, resultsPerPage: 10, }, items: vec![], }; let channel: String = video_request .channel .as_deref() .unwrap_or("all") .to_string(); let sort: String = video_request.sort.as_deref().unwrap_or("date").to_string(); let mut query: Option = video_request.query.clone(); if video_request.query.as_deref() == Some("") { query = None; } let page: u8 = video_request .page .as_ref() .and_then(|value| value.to_u8()) .unwrap_or(1); let perPage: u8 = video_request .perPage .as_ref() .and_then(|value| value.to_u8()) .unwrap_or(10); let featured = video_request .featured .as_deref() .unwrap_or("all") .to_string(); let provider = get_provider(channel.as_str()) .ok_or_else(|| web::error::ErrorBadRequest("Invalid channel".to_string()))?; let category = video_request .category .as_deref() .unwrap_or("all") .to_string(); let sites = if channel == "all" { video_request .all_provider_sites .as_deref() .or(video_request.sites.as_deref()) .unwrap_or("") .to_string() } else { video_request.sites.as_deref().unwrap_or("").to_string() }; let filter = video_request.filter.as_deref().unwrap_or("new").to_string(); let language = video_request .language .as_deref() .unwrap_or("en") .to_string(); let network = video_request.networks.as_deref().unwrap_or("").to_string(); let stars = video_request.stars.as_deref().unwrap_or("").to_string(); let categories = video_request .categories .as_deref() .unwrap_or("") .to_string(); let duration = video_request.duration.as_deref().unwrap_or("").to_string(); let sexuality = video_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(network), stars: Some(stars), categories: Some(categories), duration: Some(duration), sort: Some(sort.clone()), sexuality: Some(sexuality), }; let mut video_items = run_provider_guarded( &channel, "videos_post.get_videos", provider.get_videos( cache.get_ref().clone(), pool.get_ref().clone(), sort.clone(), query.clone(), page.to_string(), perPage.to_string(), options.clone(), ), ) .await; // There is a bug in Hottub38 that makes the client error for a 403-url even though formats work fine if clientversion == ClientVersion::new(38, 0, "Hot%20Tub".to_string()) { // filter out videos without preview for old clients 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(); } videos.items = video_items.clone(); if video_items.len() == 0 { videos.pageInfo = PageInfo { hasNextPage: false, resultsPerPage: 10, } } //### let next_page = page.to_string().parse::().unwrap_or(1) + 1; let provider_clone = provider.clone(); let cache_clone = cache.get_ref().clone(); let pool_clone = pool.get_ref().clone(); let sort_clone = sort.clone(); let query_clone = query.clone(); let per_page_clone = perPage.to_string(); let options_clone = options.clone(); let channel_clone = channel.clone(); task::spawn_local(async move { // if let AnyProvider::Spankbang(_) = provider_clone { // // Spankbang has a delay for the next page // ntex::time::sleep(ntex::time::Seconds(80)).await; // } let _ = run_provider_guarded( &channel_clone, "videos_post.prefetch_next_page", provider_clone.get_videos( cache_clone, pool_clone, sort_clone, query_clone, next_page.to_string(), per_page_clone, options_clone, ), ) .await; }); //### for video in videos.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); } } Ok(web::HttpResponse::Ok().json(&videos)) } pub fn get_provider(channel: &str) -> Option { ALL_PROVIDERS.get(channel).cloned() } pub async fn test() -> Result { let e = io::Error::new(io::ErrorKind::Other, "test error"); let _ = send_discord_error_report( e.to_string(), Some("chain_str".to_string()), Some("Context"), Some("xtra info"), file!(), line!(), module_path!(), ) .await; Ok(web::HttpResponse::Ok()) } pub async fn proxies() -> Result { let proxies = all_proxies_snapshot().await.unwrap_or_default(); let mut by_protocol: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for proxy in proxies { by_protocol .entry(proxy.protocol.clone()) .or_default() .push(proxy); } for proxies in by_protocol.values_mut() { proxies.sort_by(|a, b| { a.host .cmp(&b.host) .then(a.port.cmp(&b.port)) .then(a.username.cmp(&b.username)) .then(a.password.cmp(&b.password)) }); } Ok(web::HttpResponse::Ok().json(&by_protocol)) }