use crate::DbPool; use crate::api::ClientVersion; use crate::providers::{Provider, report_provider_error, requester_or_default}; use crate::status::*; use crate::util::cache::VideoCache; use crate::util::parse_abbreviated_number; use crate::util::time::parse_time_to_seconds; use crate::videos::{ServerOptions, VideoItem}; use async_trait::async_trait; use error_chain::error_chain; use htmlentity::entity::{ICodedDataTrait, decode}; use regex::Regex; use scraper::{ElementRef, Html, Selector}; use wreq::Version; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "studio-network", tags: &["premium", "studios", "networks", "models"], }; const BASE_URL: &str = "https://www.wow.xxx"; const CHANNEL_ID: &str = "wowxxx"; const FIREFOX_UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0"; const HTML_ACCEPT: &str = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; error_chain! { foreign_links { Io(std::io::Error); } errors { Parse(msg: String) { description("parse error") display("parse error: {}", msg) } } } #[derive(Debug, Clone)] enum Target { Latest, MostPopular, TopRated, Search(String), Archive(String), } #[derive(Debug, Clone)] pub struct WowxxxProvider; impl WowxxxProvider { pub fn new() -> Self { Self } fn build_channel(&self, _cv: ClientVersion) -> Channel { Channel { id: CHANNEL_ID.to_string(), name: "WOW.XXX".to_string(), description: "Premium studio aggregator with latest updates, search, and direct archive routing for sites/networks/models/tags/categories." .to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=wow.xxx".to_string(), status: "active".to_string(), categories: vec![], options: vec![ChannelOption { id: "sort".to_string(), title: "Sort".to_string(), description: "Browse WOW.XXX feeds.".to_string(), systemImage: "list.number".to_string(), colorName: "blue".to_string(), options: vec![ FilterOption { id: "new".to_string(), title: "Latest".to_string(), }, FilterOption { id: "popular".to_string(), title: "Most Viewed".to_string(), }, FilterOption { id: "rated".to_string(), title: "Top Rated".to_string(), }, ], multiSelect: false, }], nsfw: true, cacheDuration: Some(1800), } } fn selector(value: &str) -> Result { Selector::parse(value) .map_err(|e| Error::from(format!("selector `{value}` parse failed: {e}"))) } fn decode_html(text: &str) -> String { decode(text.as_bytes()) .to_string() .unwrap_or_else(|_| text.to_string()) } fn text_of(el: &ElementRef<'_>) -> String { let raw = el.text().collect::>().join(" "); Self::decode_html(&raw.split_whitespace().collect::>().join(" ")) } fn normalize_url(path: &str) -> String { let path = path.trim(); if path.starts_with("http://") || path.starts_with("https://") { return path.to_string(); } if path.starts_with("//") { return format!("https:{path}"); } if path.starts_with('/') { return format!("{BASE_URL}{path}"); } format!("{BASE_URL}/{path}") } fn slug_from_url(url: &str, segment: &str) -> Option { let marker = format!("/{segment}/"); let (_, tail) = url.split_once(&marker)?; let slug = tail.trim_matches('/').split('/').next()?.trim(); if slug.is_empty() { None } else { Some(slug.to_string()) } } fn normalize_search_query(query: &str) -> String { let mut cleaned = query.trim().trim_start_matches('#').to_string(); while cleaned.contains("//") { cleaned = cleaned.replace("//", "/"); } cleaned } fn page_url(base: &str, page: u16) -> String { let base = base.trim_end_matches('/'); if page <= 1 { format!("{base}/") } else { format!("{base}/{page}/") } } fn target_url(target: &Target, page: u16) -> String { match target { Target::Latest => Self::page_url(&format!("{BASE_URL}/latest-updates"), page), Target::MostPopular => Self::page_url(&format!("{BASE_URL}/most-popular/all"), page), Target::TopRated => Self::page_url(&format!("{BASE_URL}/top-rated/all"), page), Target::Search(query) => { let encoded: String = url::form_urlencoded::byte_serialize(query.trim().as_bytes()).collect(); let base = format!("{BASE_URL}/search/{encoded}/relevance"); Self::page_url(&base, page) } Target::Archive(base) => Self::page_url(base, page), } } fn resolve_query_target(query: &str) -> Option { let q = query.trim(); let lower = q.to_ascii_lowercase(); let prefixes = [ ("site:", "sites"), ("studio:", "sites"), ("network:", "networks"), ("model:", "models"), ("pornstar:", "models"), ("tag:", "tags"), ("cat:", "categories"), ("category:", "categories"), ]; for (prefix, segment) in prefixes { if let Some((_, value)) = lower.split_once(prefix) { let slug = value.trim().trim_matches('/'); if !slug.is_empty() { return Some(Target::Archive(format!("{BASE_URL}/{segment}/{slug}/"))); } } } None } fn resolve_target(query: Option<&str>, sort: &str) -> Target { if let Some(q) = query { let trimmed = q.trim(); if !trimmed.is_empty() { if let Some(shortcut) = Self::resolve_query_target(trimmed) { return shortcut; } return Target::Search(Self::normalize_search_query(trimmed)); } } match sort { "popular" => Target::MostPopular, "rated" => Target::TopRated, _ => Target::Latest, } } fn html_headers(referer: &str) -> Vec<(String, String)> { vec![ ("User-Agent".to_string(), FIREFOX_UA.to_string()), ("Accept".to_string(), HTML_ACCEPT.to_string()), ("Accept-Language".to_string(), "en-US,en;q=0.5".to_string()), ("Referer".to_string(), referer.to_string()), ] } async fn fetch_html( requester: &mut crate::util::requester::Requester, url: &str, ) -> Result { requester .get_with_headers(url, Self::html_headers(BASE_URL), Some(Version::HTTP_11)) .await .map_err(|e| Error::from(format!("request failed for {url}: {e}"))) } fn parse_duration(duration_text: &str) -> u32 { let cleaned = duration_text .replace("Full Video", "") .replace("Full", "") .trim() .to_string(); parse_time_to_seconds(&cleaned).unwrap_or(0) as u32 } fn parse_video_items(html: &str) -> Result> { let document = Html::parse_document(html); let item_sel = Self::selector("div.list-videos div.item")?; let a_sel = Self::selector("a.thumb_img")?; let title_sel = Self::selector("a.thumb_title strong.title, a.thumb_title")?; let img_sel = Self::selector("div.thumb__img img")?; let thumb_wrap_sel = Self::selector("div.thumb__img")?; let duration_sel = Self::selector("span.duration")?; let rating_sel = Self::selector("div.rating")?; let views_sel = Self::selector("div.views")?; let site_sel = Self::selector("a.models__item.thumb_cs")?; let model_sel = Self::selector("a.models__item.thumb_model")?; let id_sel = Self::selector("span.js-favourites[data-object_id]")?; let rating_re = Regex::new(r"(\d{1,3})").map_err(|e| Error::from(e.to_string()))?; let mut out = vec![]; for item in document.select(&item_sel) { let Some(anchor) = item.select(&a_sel).next() else { continue; }; let href = anchor.value().attr("href").unwrap_or("").trim(); if href.is_empty() { continue; } let video_url = Self::normalize_url(href); let id = item .select(&id_sel) .next() .and_then(|el| el.value().attr("data-object_id")) .map(|v| v.to_string()) .or_else(|| { let tail = video_url.trim_end_matches('/').split('/').next_back()?; if tail.is_empty() { None } else { Some(tail.to_string()) } }) .unwrap_or_default(); if id.is_empty() { continue; } let title = item .select(&title_sel) .next() .map(|el| Self::text_of(&el)) .filter(|value| !value.is_empty()) .unwrap_or_else(|| { anchor .value() .attr("title") .map(Self::decode_html) .unwrap_or_else(|| "Untitled".to_string()) }); let thumb = item .select(&img_sel) .next() .and_then(|img| { img.value() .attr("data-src") .or_else(|| img.value().attr("src")) }) .map(Self::normalize_url) .unwrap_or_default(); if thumb.is_empty() { continue; } let preview = item .select(&thumb_wrap_sel) .next() .and_then(|el| el.value().attr("data-preview")) .map(Self::normalize_url) .filter(|value| !value.is_empty()); let duration = item .select(&duration_sel) .next() .map(|el| Self::parse_duration(&Self::text_of(&el))) .unwrap_or(0); let views = item .select(&views_sel) .next() .map(|el| Self::text_of(&el)) .and_then(|v| parse_abbreviated_number(v.trim())) .unwrap_or(0); let rating = item .select(&rating_sel) .next() .map(|el| Self::text_of(&el)) .and_then(|raw| { rating_re .captures(&raw) .and_then(|caps| caps.get(1)) .and_then(|m| m.as_str().parse::().ok()) }); let uploader_name = item .select(&site_sel) .next() .map(|el| Self::text_of(&el)) .filter(|value| !value.is_empty()); let uploader_url = item .select(&site_sel) .next() .and_then(|el| el.value().attr("href")) .map(Self::normalize_url); let uploader_id = uploader_url .as_deref() .and_then(|url| Self::slug_from_url(url, "sites")) .map(|slug| format!("{CHANNEL_ID}:site:{slug}")); let mut tags = vec![]; for model in item.select(&model_sel) { let value = Self::text_of(&model); if !value.is_empty() { tags.push(value); } } let mut video = VideoItem::new( id, title, video_url, CHANNEL_ID.to_string(), thumb, duration, ) .views(views) .tags(tags); if let Some(r) = rating { video = video.rating(r); } if let Some(preview) = preview { video = video.preview(preview); } if let Some(uploader) = uploader_name { video = video.uploader(uploader); } if let Some(url) = uploader_url { video = video.uploader_url(url); } if let Some(uid) = uploader_id { video.uploaderId = Some(uid); } out.push(video); } Ok(out) } async fn get( &self, _db: DbPool, _cache: VideoCache, page: u16, sort: &str, query: Option, options: ServerOptions, ) -> Result> { let target = Self::resolve_target(query.as_deref(), sort); let url = Self::target_url(&target, page); let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos"); let html = Self::fetch_html(&mut requester, &url).await?; Self::parse_video_items(&html) } } #[async_trait] impl Provider for WowxxxProvider { async fn get_videos( &self, cache: VideoCache, db: DbPool, sort: String, query: Option, page: String, _per_page: String, options: ServerOptions, ) -> Vec { let page = page.parse::().unwrap_or(1).max(1); match self.get(db, cache, page, &sort, query, options).await { Ok(v) => v, Err(e) => { report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await; vec![] } } } fn get_channel(&self, clientversion: ClientVersion) -> Option { Some(self.build_channel(clientversion)) } }