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::videos::{ServerOptions, VideoItem}; use async_trait::async_trait; use error_chain::error_chain; use std::sync::{Arc, RwLock}; use std::vec; use wreq::Version; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "tiktok", tags: &["tube", "mixed", "search"], }; 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 XfreeProvider { url: String, categories: Arc>>, } impl XfreeProvider { pub fn new() -> Self { let provider = Self { url: "https://www.xfree.com".to_string(), categories: Arc::new(RwLock::new(vec![])), }; provider } fn build_channel(&self, clientversion: ClientVersion) -> Channel { let _ = clientversion; Channel { id: "xfree".to_string(), name: "XFree".to_string(), description: "Reels & Nudes!".to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=xfree.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( "xfree", "build_channel.categories_read", &e.to_string(), ); vec![] }), options: vec![ChannelOption { id: "sexuality".to_string(), title: "Sexuality".to_string(), description: "Sexuality of the Videos".to_string(), systemImage: "heart".to_string(), colorName: "red".to_string(), multiSelect: false, options: vec![ FilterOption { id: "1".to_string(), title: "Straight".to_string(), }, FilterOption { id: "2".to_string(), title: "Gay".to_string(), }, FilterOption { id: "3".to_string(), title: "Trans".to_string(), }, ], }], nsfw: true, cacheDuration: None, } } 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 query( &self, cache: VideoCache, page: u8, query: &str, options: ServerOptions, pool: DbPool, ) -> Result> { let query = if query.is_empty() { "null" } else { query }; let sexuality = match options.clone().sexuality { Some(s) if !s.is_empty() => s, _ => "1".to_string(), }; let video_url = format!( "{}/api/2/search?search={}&lgbt={}&limit=30&offset={}", self.url, query.replace(" ", "%20"), sexuality, (page as u32 - 1) * 30 ); // 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 * 60 * 24 { 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"); // let _ = requester.get("https://www.xfree.com/", Some(Version::HTTP_2)).await; let text = match requester .get_with_headers( &video_url, vec![ ("Apiversion".to_string(), "1.0".to_string()), ( "Accept".to_string(), "application/json text/plain */*".to_string(), ), ("Referer".to_string(), "https://www.xfree.com/".to_string()), ], Some(Version::HTTP_2), ) .await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "xfree", "query.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; let video_items: Vec = self .get_video_items_from_json(text.clone(), &mut requester, pool) .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) } /// Front-page feed used when there is no search query. This mirrors the /// site's homepage, which dispatches `getAutoPop` against /// `/api/post/?t=popular&nsfhp=true&limit=30&offset=N&lgbt=X` instead of the /// `/api/2/search` endpoint. (`popular` is the real feed type the homepage /// loads first; `posts` is only a Vuex store key and 404s as a `t=` value.) /// The response body is the post array directly, not `body.posts`. async fn front_page( &self, cache: VideoCache, page: u8, options: ServerOptions, pool: DbPool, ) -> Result> { let sexuality = match options.clone().sexuality { Some(s) if !s.is_empty() => s, _ => "1".to_string(), }; let offset = (page as u32 - 1) * 30; let video_url = format!( "{}/api/post/?t=popular&nsfhp=true&limit=30{}&lgbt={}", self.url, if page > 1 { format!("&offset={offset}") } else { String::new() }, sexuality, ); // 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 * 60 * 24 { 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"); let text = match requester .get_with_headers( &video_url, vec![ ("Apiversion".to_string(), "1.0".to_string()), ( "Accept".to_string(), "application/json text/plain */*".to_string(), ), ("Referer".to_string(), "https://www.xfree.com/".to_string()), ], Some(Version::HTTP_2), ) .await { Ok(text) => text, Err(e) => { crate::providers::report_provider_error( "xfree", "front_page.request", &format!("url={video_url}; error={e}"), ) .await; return Ok(old_items); } }; let video_items: Vec = self .get_video_items_from_json(text.clone(), &mut requester, pool) .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_json( &self, html: String, _requester: &mut Requester, _pool: DbPool, ) -> Vec { let mut items: Vec = Vec::new(); let json_result = serde_json::from_str::(&html); let json = match json_result { Ok(json) => json, Err(e) => { eprintln!("Failed to parse JSON: {e}"); crate::providers::report_provider_error( "xfree", "get_video_items_from_json.parse", &format!("Failed to parse JSON: {e}"), ) .await; return vec![]; } }; // The search endpoint returns `{ body: { posts: [...] } }`, while the // front-page feed (`/api/post/`) returns `{ body: [...] }` directly. // Mirror the site's own logic (`body.posts ? body.posts : body`). let empty: Vec = vec![]; let body = json.get("body"); let posts = body .and_then(|v| v.get("posts")) .and_then(|p| p.as_array()) .or_else(|| body.and_then(|v| v.as_array())) .unwrap_or(&empty); for post in posts { let id = post .get("media") .and_then(|v| v.get("name")) .and_then(|v| v.as_str()) .unwrap_or_default(); let title = post .get("title") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let video_url = format!( "https://cdn.xfree.com/xfree-prod/{}/{}/{}/{}/full.mp4", id.chars().nth(0).unwrap_or('0'), id.chars().nth(1).unwrap_or('0'), id.chars().nth(2).unwrap_or('0'), id ); let listsuffix = post .get("media") .and_then(|v| v.get("listingSuffix")) .and_then(|v| v.as_i64()) .unwrap_or_default(); let thumb = format!( "https://thumbs.xfree.com/listing/medium/{}_{}.webp", id, listsuffix ); let views = post.get("viewCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32; let preview = format!( "https://cdn.xfree.com/xfree-prod/{}/{}/{}/{}/listing7.mp4", id.chars().nth(0).unwrap_or('0'), id.chars().nth(1).unwrap_or('0'), id.chars().nth(2).unwrap_or('0'), id ); let duration = post .get("media") .and_then(|v| v.get("duration")) .and_then(|v| v.as_f64()) .unwrap_or_default() as u32; let tags = post .get("tags") .and_then(|v| v.as_array()) .unwrap_or(&vec![]) .iter() .filter_map(|t| t.get("tag").and_then(|n| n.as_str()).map(|s| s.to_string())) .collect::>(); for tag in tags.iter() { Self::push_unique( &self.categories, FilterOption { id: tag.clone(), title: tag.clone(), }, ); } let uploader = post .get("user") .and_then(|v| v.get("displayName")) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let upload_date = post .get("publishedDate") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let uploaded_at = chrono::DateTime::parse_from_rfc3339(&upload_date) .map(|dt| dt.timestamp() as u64) .unwrap_or(0); let aspect_ration = post .get("media") .and_then(|v| v.get("aspectRatio")) .and_then(|v| v.as_str()) .unwrap_or_default() .to_string() .parse::() .unwrap_or(0.5625); let video_item = VideoItem::new( id.to_string(), title, video_url, "xfree".to_string(), thumb, duration, ) .views(views) .preview(preview) .tags(tags) .uploader(uploader) .uploaded_at(uploaded_at) .aspect_ratio(aspect_ration); items.push(video_item); } return items; } } #[async_trait] impl Provider for XfreeProvider { 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 query = query.unwrap_or_default(); let res = if query.trim().is_empty() { // Empty query => front page feed, not the search endpoint. self.to_owned().front_page(cache, page, options, pool).await } else { self.to_owned() .query(cache, page, &query, options, pool) .await }; res.unwrap_or_else(|e| { eprintln!("xfree error: {e}"); vec![] }) } fn get_channel(&self, v: ClientVersion) -> Option { Some(self.build_channel(v)) } }