diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 093521f..ba25d5f 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -38,6 +38,7 @@ pub mod rule34gen; pub mod xxdbx; pub mod hqporner; pub mod noodlemagazine; +pub mod pimpbunny; // convenient alias pub type DynProvider = Arc; @@ -53,6 +54,7 @@ pub static ALL_PROVIDERS: Lazy> = Lazy::new(| m.insert("hqporner", Arc::new(hqporner::HqpornerProvider::new()) as DynProvider); m.insert("pmvhaven", Arc::new(pmvhaven::PmvhavenProvider::new()) as DynProvider); m.insert("noodlemagazine", Arc::new(noodlemagazine::NoodlemagazineProvider::new()) as DynProvider); + m.insert("pimpbunny", Arc::new(pimpbunny::PimpbunnyProvider::new()) as DynProvider); // add more here as you migrate them m }); diff --git a/src/providers/pimpbunny.rs b/src/providers/pimpbunny.rs new file mode 100644 index 0000000..551f3bc --- /dev/null +++ b/src/providers/pimpbunny.rs @@ -0,0 +1,555 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::Provider; +use crate::status::*; +use crate::util::cache::VideoCache; +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 error_chain::error_chain; +use futures::future::join_all; +use htmlentity::entity::{ICodedDataTrait, decode}; +use std::sync::{Arc, RwLock}; +use std::{thread, vec}; +use titlecase::Titlecase; +use wreq::Version; + +error_chain! { + foreign_links { + Io(std::io::Error); + HttpRequest(wreq::Error); + } +} + +#[derive(Debug, Clone)] +pub struct PimpbunnyProvider { + url: String, + stars: Arc>>, + categories: Arc>>, +} +impl PimpbunnyProvider { + pub fn new() -> Self { + let provider = PimpbunnyProvider { + url: "https://pimpbunny.com".to_string(), + stars: Arc::new(RwLock::new(vec![])), + categories: Arc::new(RwLock::new(vec![])), + }; + provider.spawn_initial_load(); + provider + } + + fn spawn_initial_load(&self) { + let url = self.url.clone(); + let stars = Arc::clone(&self.stars); + let categories = Arc::clone(&self.categories); + + thread::spawn(move || { + // Create a tiny runtime just for these async tasks + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + + rt.block_on(async move { + if let Err(e) = Self::load_stars(&url, stars).await { + eprintln!("load_stars failed: {e}"); + } + if let Err(e) = Self::load_categories(&url, categories).await { + eprintln!("load_categories failed: {e}"); + } + }); + }); + } + + async fn load_stars(base_url: &str, stars: Arc>>) -> Result<()> { + let mut requester = Requester::new(); + let text = requester + .get( + format!("{}/onlyfans-models/?models_per_page=20", &base_url).as_str(), + Some(Version::HTTP_2), + ) + .await + .unwrap(); + let stars_div = text + .split("pb-list-models-block") + .collect::>() + .last() + .unwrap() + .split("pb-page-description") + .collect::>()[0]; + for stars_element in stars_div + .split("
") + .collect::>()[1..] + .to_vec() + { + if stars_element.contains("pb-promoted-link") { + continue; + } + let star_id = stars_element + .split("href=\"https://pimpbunny.com/onlyfans-models/") + .collect::>()[1] + .split("/\"") + .collect::>()[0] + .to_string(); + let star_name = stars_element + .split("
") + .collect::>()[1] + .split("<") + .collect::>()[0] + .to_string(); + Self::push_unique( + &stars, + FilterOption { + id: star_id, + title: star_name, + }, + ); + } + return Ok(()); + } + + async fn load_categories( + base_url: &str, + categories: Arc>>, + ) -> Result<()> { + let mut requester = Requester::new(); + let text = requester + .get( + format!("{}/categories/?items_per_page=120", &base_url).as_str(), + Some(Version::HTTP_2), + ) + .await + .unwrap(); + let categories_div = text + .split("list_categories_categories_list_items") + .collect::>() + .last() + .unwrap() + .split("pb-pagination-wrapper") + .collect::>()[0]; + for categories_element in categories_div + .split("
") + .collect::>()[1..] + .to_vec() + { + let category_id = categories_element + .split("href=\"https://pimpbunny.com/categories/") + .collect::>()[1] + .split("/\"") + .collect::>()[0] + .to_string(); + let category_name = categories_element + .split("
") + .collect::>()[1] + .split("<") + .collect::>()[0] + .titlecase(); + Self::push_unique( + &categories, + FilterOption { + id: category_id, + title: category_name, + }, + ); + } + return Ok(()); + } + + fn build_channel(&self, clientversion: ClientVersion) -> Channel { + let _ = clientversion; + Channel { + id: "pimpbunny".to_string(), + name: "Pimpbunny".to_string(), + description: "Watch Porn!".to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=pimpbunny.com".to_string(), + status: "active".to_string(), + categories: self + .categories + .read() + .unwrap() + .iter() + .map(|c| c.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: "featured".into(), + title: "Featured".into(), + }, + FilterOption { + id: "most recent".into(), + title: "Most Recent".into(), + }, + FilterOption { + id: "most viewed".into(), + title: "Most Viewed".into(), + }, + FilterOption { + id: "best rated".into(), + title: "Best Rated".into(), + }, + ], + multiSelect: false, + }], + nsfw: true, + cacheDuration: None, + } + } + + // Push one item with minimal lock time and dedup by id + fn push_unique(target: &Arc>>, item: FilterOption) { + if let Ok(mut vec) = target.write() { + if !vec.iter().any(|x| x.id == item.id) { + vec.push(item); + // Optional: keep it sorted for nicer UX + // vec.sort_by(|a,b| a.title.cmp(&b.title)); + } + } + } + + async fn get( + &self, + cache: VideoCache, + page: u8, + sort: &str, + options: ServerOptions, + ) -> Result> { + let sort_string = match sort { + "best rated" => "&sort_by=rating", + "most viewed" => "&sort_by=video_viewed", + _ => "&sort_by=post_date", + }; + let video_url = format!( + "{}/videos/{}/?videos_per_page=20{}", + self.url, page, sort_string + ); + let old_items = match cache.get(&video_url) { + Some((time, items)) => { + if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 { + println!("Cache hit for URL: {}", video_url); + return Ok(items.clone()); + } else { + items.clone() + } + } + None => { + vec![] + } + }; + + let mut requester = options.requester.clone().unwrap(); + let text = requester.get(&video_url, Some(Version::HTTP_2)).await.unwrap(); + let video_items: Vec = self + .get_video_items_from_html(text.clone(), &mut requester) + .await; + 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 search_string = query.trim().to_string(); + + let mut video_url = format!( + "{}/search/{}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&videos_per_page=20&from_videos={}", + self.url, search_string.replace(" ","-"), page + ); + + let sort_string = match options.sort.as_deref().unwrap_or("") { + "best rated" => "&sort_by=rating", + "most viewed" => "&sort_by=video_viewed", + _ => "&sort_by=post_date", + }; + if let Some(star) = self + .stars + .read() + .unwrap() + .iter() + .find(|s| s.title.to_ascii_lowercase() == search_string.to_ascii_lowercase()) + { + video_url = format!( + "{}/onlyfans-models/{}/{}/?videos_per_page=20{}", + self.url, star.id, page, sort_string + ); + } + if let Some(cat) = self + .categories + .read() + .unwrap() + .iter() + .find(|c| c.title.to_ascii_lowercase() == search_string.to_ascii_lowercase()) + { + video_url = format!( + "{}/categories/{}/{}/?videos_per_page=20{}", + self.url, cat.id, page, sort_string + ); + } + // 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 * 5 { + 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(&video_url, Some(Version::HTTP_2)).await.unwrap(); + let video_items: Vec = self + .get_video_items_from_html(text.clone(), &mut requester) + .await; + 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 get_video_items_from_html( + &self, + html: String, + requester: &mut Requester, + ) -> Vec { + if html.is_empty() || html.contains("404 Not Found") { + return vec![]; + } + let raw_videos = html.split("pb-pagination-wrapper").collect::>()[0] + .split("pb-list-items") + .collect::>()[1] + .split("
") + .collect::>()[1..] + .to_vec(); + + let futures = raw_videos + .into_iter() + .map(|el| self.get_video_item(el.to_string(), requester.clone())); + let results: Vec> = join_all(futures).await; + let video_items: Vec = results.into_iter().filter_map(Result::ok).collect(); + return video_items; + } + + async fn get_video_item( + &self, + video_segment: String, + mut requester: Requester, + ) -> Result { + let video_url: String = video_segment.split(" href=\"").collect::>()[1] + .split("\"") + .collect::>()[0] + .to_string(); + let mut title = video_segment.split("pb-item-title").collect::>()[1] + .split(">") + .collect::>()[1] + .split("<") + .collect::>()[0] + .trim() + .to_string(); + // html decode + title = decode(title.as_bytes()) + .to_string() + .unwrap_or(title) + .titlecase(); + let id = video_url.split("/").collect::>()[4] + .split(".") + .collect::>()[0] + .to_string(); + + let mut thumb = video_segment.split("pb-thumbnail").collect::>()[1] + .split("src=\"") + .collect::>()[1] + .split("\"") + .collect::>()[0] + .to_string(); + if thumb.starts_with("data:image/jpg;base64") { + thumb = video_segment.split("pb-thumbnail").collect::>()[1] + .split("data-webp=\"") + .collect::>()[1] + .split("\"") + .collect::>()[0] + .to_string(); + } + let preview = video_segment.split("pb-thumbnail").collect::>()[1] + .split("data-preview=\"") + .collect::>()[1] + .split("\"") + .collect::>()[0] + .to_string(); + + let (tags, formats, views, duration) = match self.extract_media(&video_url, &mut requester).await { + Ok((t, f, v, d)) => (t, f, v, d), + Err(_) => return Err(Error::from("Video media extraction failed")), + }; + if formats.is_empty() { + return Err(Error::from("No formats found for video")); + } + let video_item = VideoItem::new( + id, + title, + video_url, + "pimpbunny".to_string(), + thumb, + duration, + ) + .formats(formats) + .tags(tags) + .preview(preview) + .views(views) + ; + return Ok(video_item); + } + + async fn extract_media( + &self, + video_page_url: &str, + requester: &mut Requester, + ) -> Result<(Vec, Vec, u32, u32)> { + let mut formats = vec![]; + let mut tags = vec![]; + let text = requester + .get(&video_page_url, Some(Version::HTTP_2)) + .await + .unwrap(); + if text.contains("pb-video-models"){ + let stars_elements = text.split("pb-video-models").collect::>()[1] + .split("pb-video-statistic") + .collect::>()[0] + .split("pb-models-item pb-models-item") + .collect::>()[1..] + .to_vec(); + for star_el in stars_elements { + let star_id = star_el + .split("href=\"https://pimpbunny.com/onlyfans-models/") + .collect::>()[1] + .split("/\"") + .collect::>()[0] + .to_string(); + let star_name = star_el + .split("") + .collect::>()[1] + .split("<") + .collect::>()[0] + .to_string(); + tags.push(star_name.clone()); + Self::push_unique( + &self.stars, + FilterOption { + id: star_id, + title: star_name.clone(), + }, + ); + } + } + if text.contains("pb-video-tags") { + let categories_elements = text.split("pb-tags-list").collect::>()[1] + .split("
") + .collect::>()[0] + .split("href=\"https://pimpbunny.com/tags/") + .collect::>()[1..] + .to_vec(); + for categories_el in categories_elements { + let category_id = categories_el.split("\"").collect::>()[0].to_string(); + let category_name = categories_el.split("\">").collect::>()[1] + .split("<") + .collect::>()[0] + .titlecase(); + tags.push(category_name.clone()); + Self::push_unique( + &self.categories, + FilterOption { + id: category_id, + title: category_name.clone(), + }, + ); + } + } + + let json_str = text + .split(";") + .collect::>()[0]; + let json = serde_json::from_str::(json_str).unwrap_or_default(); + let video_url = json["contentUrl"].as_str().unwrap_or("").to_string(); + let quality = video_url + .split("_") + .collect::>() + .last() + .map_or("", |v| v) + .split(".") + .collect::>()[0] + .to_string(); + let views = json["interactionStatistic"].as_array().unwrap()[0]["userInteractionCount"] + .as_str().unwrap().parse::().unwrap_or(0); + let raw_duration = json["duration"].as_str().unwrap_or("00:00").replace("PT", "").replace("H", ":").replace("M", ":").replace("S", ""); + let duration = parse_time_to_seconds(raw_duration.as_str()).unwrap_or(0) as u32; + formats.push(VideoFormat::new( + video_url, + quality.clone(), + "video/mp4".to_string(), + )); + Ok((tags, formats, views, duration)) + } +} + +#[async_trait] +impl Provider for PimpbunnyProvider { + 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), &sort, options) + .await + } + }; + match videos { + Ok(v) => v, + Err(e) => { + println!("Error fetching videos: {}", e); + vec![] + } + } + } + fn get_channel(&self, clientversion: ClientVersion) -> crate::status::Channel { + self.build_channel(clientversion) + } +}