use crate::DbPool; use crate::api::ClientVersion; use crate::providers::Provider; use crate::status::*; use crate::util::cache::VideoCache; use crate::util::discord::{format_error_chain, send_discord_error_report}; 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); Json(serde_json::Error); } errors { Parse(msg: String) { description("parse error") display("parse error: {}", msg) } } } #[derive(Debug, Clone)] pub struct PimpbunnyProvider { url: String, stars: Arc>>, categories: Arc>>, } impl PimpbunnyProvider { pub fn new() -> Self { let provider = Self { url: "https://pimpbunny.com".to_string(), stars: Arc::new(RwLock::new(vec![])), categories: Arc::new(RwLock::new(vec![])), }; provider.spawn_initial_load(); provider } 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() .map(|categories| categories.iter().map(|c| c.title.clone()).collect()) .unwrap_or_else(|e| { crate::providers::report_provider_error_background( "pimpbunny", "build_channel.categories_read", &e.to_string(), ); 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: "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, } } fn spawn_initial_load(&self) { let url = self.url.clone(); let stars = Arc::clone(&self.stars); let categories = Arc::clone(&self.categories); thread::spawn(async move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(rt) => rt, Err(e) => { eprintln!("tokio runtime failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to create tokio runtime"), file!(), line!(), module_path!(), ) .await; return; } }; rt.block_on(async { if let Err(e) = Self::load_stars(&url, Arc::clone(&stars)).await { eprintln!("load_stars failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to load stars during initial load"), file!(), line!(), module_path!(), ) .await; } if let Err(e) = Self::load_categories(&url, Arc::clone(&categories)).await { eprintln!("load_categories failed: {e}"); send_discord_error_report( e.to_string(), Some(format_error_chain(&e)), Some("Pimpbunny Provider"), Some("Failed to load categories during initial load"), file!(), line!(), module_path!(), ) .await; } }); }); } 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); } } } async fn load_stars(base: &str, stars: Arc>>) -> Result<()> { let mut requester = Requester::new(); let text = requester .get( &format!("{base}/onlyfans-models/?models_per_page=20"), Some(Version::HTTP_2), ) .await .map_err(|e| Error::from(format!("{}", e)))?; let block = text .split("vt_list_models_with_advertising_custom_models_list_items") .last() .ok_or_else(|| ErrorKind::Parse("missing stars block".into()))? .split("pb-page-description") .next() .unwrap_or(""); for el in block.split("
").skip(1) { if el.contains("pb-promoted-link") || !el.contains("href=\"https://pimpbunny.com/onlyfans-models/") { continue; } let id = el .split("href=\"https://pimpbunny.com/onlyfans-models/") .nth(1) .and_then(|s| s.split("/\"").next()) .ok_or_else(|| ErrorKind::Parse(format!("star id: {el}").into()))? .to_string(); let title = el .split("ui-card-title") .nth(1) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse(format!("star title: {el}").into()))? .to_string(); Self::push_unique(&stars, FilterOption { id, title }); } Ok(()) } async fn load_categories(base: &str, cats: Arc>>) -> Result<()> { let mut requester = Requester::new(); let text = requester .get( &format!("{base}/categories/?items_per_page=120"), Some(Version::HTTP_2), ) .await .map_err(|e| Error::from(format!("{}", e)))?; let block = text .split("list_categories_categories_list_items") .last() .ok_or_else(|| ErrorKind::Parse("categories block".into()))? .split("pb-pagination-wrapper") .next() .unwrap_or(""); for el in block.split("
").skip(1) { let id = el .split("href=\"https://pimpbunny.com/categories/") .nth(1) .and_then(|s| s.split("/\"").next()) .ok_or_else(|| ErrorKind::Parse(format!("category id: {el}").into()))? .to_string(); let title = el .split("ui-heading-h3") .nth(1) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse(format!("category title: {el}").into()))? .titlecase(); Self::push_unique(&cats, FilterOption { id, title }); } Ok(()) } 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=32{}", 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 { return Ok(items.clone()); } else { items.clone() } } None => { vec![] } }; let mut requester = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); let text = match requester.get(&video_url, Some(Version::HTTP_11)).await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "pimpbunny", "get.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; 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=32&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 Ok(stars) = self.stars.read() { if let Some(star) = stars .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 ); } } else { crate::providers::report_provider_error_background( "pimpbunny", "query.stars_read", "failed to lock stars", ); } if let Ok(categories) = self.categories.read() { if let Some(cat) = categories .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 ); } } else { crate::providers::report_provider_error_background( "pimpbunny", "query.categories_read", "failed to lock categories", ); } // 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 = crate::providers::requester_or_default(&options, module_path!(), "missing_requester"); println!("Fetching URL: {}", video_url); let text = match requester.get(&video_url, Some(Version::HTTP_2)).await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "pimpbunny", "query.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; 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 block = match html .split("-pagination-wrapper") .next() .and_then(|s| s.split("videos_videos_list").nth(2)) { Some(b) => b, None => return vec![], }; let futures = block .split("
") .skip(1) .map(|el| self.get_video_item(el.to_string(), requester.clone())); join_all(futures) .await .into_iter() .filter_map(Result::ok) .collect() } async fn get_video_item(&self, seg: String, mut requester: Requester) -> Result { let video_url = seg .split(" href=\"") .nth(1) .and_then(|s| s.split('"').next()) .ok_or_else(|| ErrorKind::Parse("video url".into()))? .to_string(); let mut title = seg .split("card-title") .nth(1) .and_then(|s| s.split('>').nth(1)) .and_then(|s| s.split('<').next()) .ok_or_else(|| ErrorKind::Parse("video title".into()))? .trim() .to_string(); title = decode(title.as_bytes()) .to_string() .unwrap_or(title) .titlecase(); let id = video_url .split('/') .nth(4) .and_then(|s| s.split('.').next()) .ok_or_else(|| ErrorKind::Parse("video id".into()))? .to_string(); let thumb_block = seg .split("card-thumbnail") .nth(1) .ok_or_else(|| ErrorKind::Parse("thumb block".into()))?; let mut thumb = thumb_block .split("src=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); if thumb.contains("data:image/jpg;base64") { thumb = thumb_block .split("data-original=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); } let preview = thumb_block .split("data-preview=\"") .nth(1) .and_then(|s| s.split('"').next()) .unwrap_or("") .to_string(); let (tags, formats, views, duration) = self.extract_media(&video_url, &mut requester).await?; Ok( VideoItem::new(id, title, video_url, "pimpbunny".into(), thumb, duration) .formats(formats) .tags(tags) .preview(preview) .views(views), ) } async fn extract_media( &self, url: &str, requester: &mut Requester, ) -> Result<(Vec, Vec, u32, u32)> { let text = requester .get(url, Some(Version::HTTP_2)) .await .map_err(|e| Error::from(format!("{}", e)))?; let json_str = text .split("application/ld+json\">") .nth(1) .and_then(|s| s.split("").next()) .ok_or_else(|| ErrorKind::Parse("ld+json".into()))?; let json: serde_json::Value = serde_json::from_str(json_str)?; let video_url = json["contentUrl"].as_str().unwrap_or("").to_string(); let quality = video_url .split('_') .last() .and_then(|s| s.split('.').next()) .unwrap_or("") .to_string(); let views = json["interactionStatistic"] .as_array() .and_then(|a| a.first()) .and_then(|v| v["userInteractionCount"].as_str()) .and_then(|v| v.parse().ok()) .unwrap_or(0); let duration = json["duration"] .as_str() .map(|d| parse_time_to_seconds(&d.replace(['P', 'T', 'H', 'M', 'S'], "")).unwrap_or(0)) .unwrap_or(0) as u32; Ok(( vec![], vec![VideoFormat::new(video_url, quality, "video/mp4".into())], 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 page = page.parse::().unwrap_or(1); let res = match query { Some(q) => self.to_owned().query(cache, page, &q, options).await, None => self.get(cache, page, &sort, options).await, }; res.unwrap_or_else(|e| { eprintln!("pimpbunny error: {e}"); vec![] }) } fn get_channel(&self, v: ClientVersion) -> Option { Some(self.build_channel(v)) } }