From 9964c11a8a9f96181c476744477aaf89c862acd6 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 18:21:30 +0000 Subject: [PATCH] chaturbate --- src/providers/chaturbate.rs | 277 ++++++++++++++++++++++++++++++++++++ src/providers/mod.rs | 2 + 2 files changed, 279 insertions(+) create mode 100644 src/providers/chaturbate.rs diff --git a/src/providers/chaturbate.rs b/src/providers/chaturbate.rs new file mode 100644 index 0000000..a4360c5 --- /dev/null +++ b/src/providers/chaturbate.rs @@ -0,0 +1,277 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::Provider; +use crate::status::*; +use crate::util::cache::VideoCache; +use crate::videos::{ServerOptions, VideoItem}; +use async_trait::async_trait; +use error_chain::error_chain; +use htmlentity::entity::{ICodedDataTrait, decode}; + +error_chain! { + foreign_links { + Io(std::io::Error); + HttpRequest(wreq::Error); + } +} + +#[derive(Debug, Clone)] +pub struct ChaturbateProvider { + url: String, +} +impl ChaturbateProvider { + pub fn new() -> Self { + let provider = ChaturbateProvider { + url: "https://chaturbate.com".to_string(), + }; + provider + } + + fn build_channel(&self, clientversion: ClientVersion) -> Channel { + let _ = clientversion; + + Channel { + id: "chaturbate".to_string(), + name: "Chaturbate".to_string(), + description: "Free Adult Webcams".to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=chaturbate.com".to_string(), + status: "active".to_string(), + categories: vec![], + 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: "latest-updates".into(), + title: "Latest".into(), + }, + FilterOption { + id: "most-popular".into(), + title: "Most Viewed".into(), + }, + FilterOption { + id: "top-rated".into(), + title: "Top Rated".into(), + }, + ], + multiSelect: false, + }], + nsfw: true, + cacheDuration: None, + } + } + + async fn get( + &self, + cache: VideoCache, + page: u8, + options: ServerOptions, + ) -> Result> { + let video_url = format!( + "{}/api/ts/roomlist/room-list/?limit=90&offset={}", + self.url, + 90 * (page - 1) + ); + let old_items = match cache.get(&video_url) { + Some((time, items)) => { + if time.elapsed().unwrap_or_default().as_secs() < 60 * 1 { + return Ok(items.clone()); + } else { + items.clone() + } + } + None => { + vec![] + } + }; + + let mut requester = options.requester.clone().unwrap(); + let text = requester + .get_raw_with_headers( + &video_url, + vec![("X-Requested-With".to_string(), "XMLHttpRequest".to_string())], + ) + .await + .unwrap() + .text() + .await + .unwrap(); + let video_items: Vec = self.get_video_items_from_html(text.clone()); + if !video_items.is_empty() { + cache.remove(&video_url); + cache.insert(video_url.clone(), video_items.clone()); + } else { + return Ok(old_items); + } + Ok(video_items) + } + + async fn query( + &self, + cache: VideoCache, + page: u8, + query: &str, + options: ServerOptions, + ) -> Result> { + let mut video_url = format!( + "{}/api/ts/roomlist/room-list/?keywords={}&limit=90&offset={}", + query, + self.url, + 90 * (page - 1) + ); + video_url = video_url.replace(" ", "+"); + // Check our Video Cache. If the result is younger than 1 hour, we return it. + let old_items = match cache.get(&video_url) { + Some((time, items)) => { + if time.elapsed().unwrap_or_default().as_secs() < 60 * 1 { + return Ok(items.clone()); + } else { + let _ = cache.check().await; + return Ok(items.clone()); + } + } + None => { + vec![] + } + }; + + let mut requester = options.requester.clone().unwrap(); + + let text = requester + .get_raw_with_headers( + &video_url, + vec![("X-Requested-With".to_string(), "XMLHttpRequest".to_string())], + ) + .await + .unwrap() + .text() + .await + .unwrap(); + let video_items: Vec = self.get_video_items_from_html(text.clone()); + if !video_items.is_empty() { + cache.remove(&video_url); + cache.insert(video_url.clone(), video_items.clone()); + } else { + return Ok(old_items); + } + Ok(video_items) + } + + fn get_video_items_from_html(&self, html: String) -> Vec { + if html.is_empty() { + println!("HTML is empty"); + return vec![]; + } + let mut items = Vec::new(); + let json = serde_json::from_str::(html.as_str()).unwrap_or_else(|e| { + println!("Failed to parse JSON: {}", e); + serde_json::Value::Null + }); + for video_segment in json.get("rooms").unwrap().as_array().unwrap_or(&vec![]) { + if video_segment + .get("has_password") + .unwrap_or(&serde_json::Value::Bool(false)) + .as_bool() + .unwrap_or(false) + { + continue; + } + // let vid = video_segment.split("\n").collect::>(); + // for (index, line) in vid.iter().enumerate() { + // println!("Line {}: {}", index, line); + // } + let username = video_segment + .get("username") + .and_then(|v| v.as_str()) + .and_then(|s| s.get(2..s.len().saturating_sub(2))) + .map(String::from).unwrap(); + let video_url: String = format!("{}/{}/", self.url, username); + let mut title = video_segment + .get("room_subject") + .and_then(|v| v.as_str()) + .and_then(|s| s.get(2..s.len().saturating_sub(2))) + .map(String::from).unwrap_or("".to_string()); + // html decode + title = decode(title.as_bytes()).to_string().unwrap_or(title); + let id = username.clone(); + + let thumb = video_segment + .get("img") + .unwrap_or(&serde_json::Value::String("".to_string())) + .as_str() + .unwrap_or("") + .to_string(); + let views = video_segment + .get("viewers") + .unwrap_or(&serde_json::Value::Number(serde_json::Number::from(0))) + .as_u64() + .unwrap_or(0); + + let tags = video_segment + .get("tags") + .unwrap_or(&serde_json::Value::Array(vec![])) + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|t| t.as_str()) + .map(|s| s.to_string()) + .collect::>(); + + let video_item = VideoItem::new( + id, + title, + video_url.to_string(), + "chaturbate".to_string(), + thumb, + 0, + ) + .views(views as u32) + .uploader(username.clone()) + .uploader_url(video_url.clone()) + .tags(tags); + items.push(video_item); + } + return items; + } +} + +#[async_trait] +impl Provider for ChaturbateProvider { + async fn get_videos( + &self, + cache: VideoCache, + pool: DbPool, + _sort: String, + query: Option, + page: String, + per_page: String, + options: ServerOptions, + ) -> Vec { + let _ = per_page; + let _ = pool; + let videos: std::result::Result, Error> = match query { + Some(q) => { + self.query(cache, page.parse::().unwrap_or(1), &q, options) + .await + } + None => { + self.get(cache, page.parse::().unwrap_or(1), options) + .await + } + }; + match videos { + Ok(v) => v, + Err(e) => { + println!("Error fetching videos: {}", e); + vec![] + } + } + } + fn get_channel(&self, clientversion: ClientVersion) -> Option { + Some(self.build_channel(clientversion)) + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index dc3c0eb..3b7e4e4 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -43,6 +43,7 @@ pub mod javtiful; pub mod hypnotube; pub mod freepornvideosxxx; pub mod hentaihaven; +pub mod chaturbate; // convenient alias pub type DynProvider = Arc; @@ -63,6 +64,7 @@ pub static ALL_PROVIDERS: Lazy> = Lazy::new(| m.insert("hypnotube", Arc::new(hypnotube::HypnotubeProvider::new()) as DynProvider); m.insert("freepornvideosxxx", Arc::new(freepornvideosxxx::FreepornvideosxxxProvider::new()) as DynProvider); m.insert("hentaihaven", Arc::new(hentaihaven::HentaihavenProvider::new()) as DynProvider); + m.insert("chaturbate", Arc::new(chaturbate::ChaturbateProvider::new()) as DynProvider); // add more here as you migrate them m });