diff --git a/build.rs b/build.rs index 6e201c1..45a8c8b 100644 --- a/build.rs +++ b/build.rs @@ -326,6 +326,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "thepornbunny", ty: "ThepornbunnyProvider", }, + ProviderDef { + id: "eporner", + module: "eporner", + ty: "EpornerProvider", + }, ]; fn main() { diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index 7d8625c..4aeed24 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -67,6 +67,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us | `jable` | `jav` | no | yes | HTML JAV archive scraper; extracts `var hlsUrl` from detail pages; m3u8 format requires Referer + browser User-Agent; proxy route handles HEAD (200 OK) and GET (redirect to watch page) since yt-dlp blocks jable.tv; tag/category/model shortcut queries. | | `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. | | `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. | +| `eporner` | `mainstream-tube` | no | no | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); search queries map to `/tag/{slug}/` (eporner redirects all keyword searches to tag pages — 404 tag pages still return related content); supports sort: new/popular/rated/best; 65 hardcoded categories via `cat:`, `tag:`, `pornstar:`, `uploader:` query shortcuts; background-loads pornstar name→URL map from `/pornstar-list/`; yt-dlp resolves `video.url` natively (Eporner extractor); no proxy needed. | ## Proxy Routes diff --git a/src/providers/eporner.rs b/src/providers/eporner.rs new file mode 100644 index 0000000..768e6a2 --- /dev/null +++ b/src/providers/eporner.rs @@ -0,0 +1,643 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::{ + Provider, report_provider_error, report_provider_error_background, requester_or_default, +}; +use crate::status::*; +use crate::util::cache::VideoCache; +use crate::util::parse_abbreviated_number; +use crate::util::requester::Requester; +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 scraper::{ElementRef, Html, Selector}; +use std::sync::{Arc, RwLock}; +use std::{collections::HashMap, thread, vec}; +use wreq::Version; + +pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = + crate::providers::ProviderChannelMetadata { + group_id: "mainstream-tube", + tags: &["tube", "hd", "mixed", "search"], + }; + +const BASE_URL: &str = "https://www.eporner.com"; +const CHANNEL_ID: &str = "eporner"; +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) + } + } +} + +// Static category list — eporner categories are stable +const CATEGORIES: &[(&str, &str)] = &[ + ("4k-porn", "4K Ultra HD"), + ("60fps", "60 FPS"), + ("amateur", "Amateur"), + ("anal", "Anal"), + ("asian", "Asian"), + ("asmr", "ASMR"), + ("bbw", "BBW"), + ("bdsm", "BDSM"), + ("big-ass", "Big Ass"), + ("big-dick", "Big Dick"), + ("big-tits", "Big Tits"), + ("bisexual", "Bisexual"), + ("blonde", "Blonde"), + ("blowjob", "Blowjob"), + ("bondage", "Bondage"), + ("brunette", "Brunette"), + ("bukkake", "Bukkake"), + ("creampie", "Creampie"), + ("cumshot", "Cumshot"), + ("double-penetration", "Double Penetration"), + ("ebony", "Ebony"), + ("fat", "Fat"), + ("fetish", "Fetish"), + ("fisting", "Fisting"), + ("footjob", "Footjob"), + ("for-women", "For Women"), + ("gay", "Gay"), + ("group-sex", "Group Sex"), + ("handjob", "Handjob"), + ("hardcore", "Hardcore"), + ("hd-1080p", "HD 1080p"), + ("hentai", "Hentai"), + ("homemade", "Homemade"), + ("hotel", "Hotel"), + ("indian", "Indian"), + ("interracial", "Interracial"), + ("japanese", "Japanese"), + ("latina", "Latina"), + ("lesbians", "Lesbian"), + ("lingerie", "Lingerie"), + ("massage", "Massage"), + ("masturbation", "Masturbation"), + ("mature", "Mature"), + ("milf", "MILF"), + ("nurse", "Nurse"), + ("office", "Office"), + ("orgy", "Orgy"), + ("outdoor", "Outdoor"), + ("petite", "Petite"), + ("pornstar", "Pornstar"), + ("pov-porn", "POV"), + ("public", "Public"), + ("redhead", "Redhead"), + ("shemale", "Shemale"), + ("small-tits", "Small Tits"), + ("squirt", "Squirt"), + ("striptease", "Striptease"), + ("teens", "Teen"), + ("threesome", "Threesome"), + ("toys", "Toys"), + ("uncategorized", "Uncategorized"), + ("uniform", "Uniform"), + ("vintage", "Vintage"), + ("vr-porn", "VR Porn"), + ("webcam", "Webcam"), +]; + +#[derive(Debug, Clone)] +enum Target { + Latest, + MostViewed, + TopRated, + BestVideos, + Search(String), + Archive(String), +} + +#[derive(Debug, Clone)] +pub struct EpornerProvider { + pornstar_map: Arc>>, +} + +impl EpornerProvider { + pub fn new() -> Self { + let provider = Self { + pornstar_map: Arc::new(RwLock::new(HashMap::new())), + }; + provider.spawn_initial_load(); + provider + } + + fn spawn_initial_load(&self) { + let pornstar_map = Arc::clone(&self.pornstar_map); + thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + report_provider_error_background( + CHANNEL_ID, + "spawn_initial_load.runtime_build", + &e.to_string(), + ); + return; + } + }; + runtime.block_on(async move { + if let Err(e) = Self::load_pornstars(Arc::clone(&pornstar_map)).await { + report_provider_error_background( + CHANNEL_ID, + "load_pornstars", + &e.to_string(), + ); + } + }); + }); + } + + fn build_channel(&self, _cv: ClientVersion) -> Channel { + let mut cat_options: Vec = vec![FilterOption { + id: "all".to_string(), + title: "All".to_string(), + }]; + for (slug, label) in CATEGORIES { + cat_options.push(FilterOption { + id: slug.to_string(), + title: label.to_string(), + }); + } + + Channel { + id: CHANNEL_ID.to_string(), + name: "EPorner".to_string(), + description: + "EPorner — 5M+ free HD porn videos with latest, most viewed, top rated, category, tag, and pornstar routing." + .to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=eporner.com".to_string(), + status: "active".to_string(), + categories: CATEGORIES.iter().map(|(_, label)| label.to_string()).collect(), + options: vec![ + ChannelOption { + id: "sort".to_string(), + title: "Sort".to_string(), + description: "Browse EPorner ranking 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(), + }, + FilterOption { + id: "best".to_string(), + title: "Best Videos".to_string(), + }, + ], + multiSelect: false, + }, + ChannelOption { + id: "categories".to_string(), + title: "Categories".to_string(), + description: "Browse an EPorner category archive.".to_string(), + systemImage: "square.grid.2x2".to_string(), + colorName: "orange".to_string(), + options: cat_options, + 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 collapse_ws(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") + } + + fn text_of(el: &ElementRef<'_>) -> String { + Self::decode_html(&Self::collapse_ws(&el.text().collect::>().join(" "))) + } + + fn normalize_key(s: &str) -> String { + s.trim() + .trim_start_matches('#') + .replace(['_', '-'], " ") + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() + } + + 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 html_headers(referer: &str) -> Vec<(String, String)> { + vec![ + ("User-Agent".to_string(), FIREFOX_UA.to_string()), + ("Accept".to_string(), HTML_ACCEPT.to_string()), + ("Referer".to_string(), referer.to_string()), + ] + } + + // Build a page URL: page 1 → `{base}/`, page N → `{base}/{N}/` + 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(BASE_URL, page), + Target::MostViewed => Self::page_url(&format!("{BASE_URL}/most-viewed"), page), + Target::TopRated => Self::page_url(&format!("{BASE_URL}/top-rated"), page), + Target::BestVideos => Self::page_url(&format!("{BASE_URL}/best-videos"), page), + Target::Search(q) => { + let slug = q.trim().replace(' ', "-").to_ascii_lowercase(); + Self::page_url(&format!("{BASE_URL}/tag/{slug}"), page) + } + Target::Archive(url) => Self::page_url(url, page), + } + } + + async fn fetch_html(requester: &mut Requester, url: &str) -> Result { + requester + .get_with_headers(url, Self::html_headers(url), Some(Version::HTTP_11)) + .await + .map_err(|e| Error::from(format!("request failed for {url}: {e}"))) + } + + fn parse_duration(text: &str) -> u32 { + parse_time_to_seconds(text) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(0) + } + + fn parse_views(text: &str) -> Option { + let cleaned = text + .replace("views", "") + .replace("view", "") + .replace([',', ' '], ""); + parse_abbreviated_number(cleaned.trim()) + } + + fn parse_rating_pct(text: &str) -> Option { + let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok().map(|v| v / 100.0) + } + + fn parse_list_page(html: &str) -> Result> { + let document = Html::parse_document(html); + let card_sel = Self::selector("div.mb[data-id]")?; + let img_sel = Self::selector("div.mbimg a img[src]")?; + let link_sel = Self::selector("p.mbtit a[href], div.mbtit a[href]")?; + let dur_sel = Self::selector("span.mbtim")?; + let rate_sel = Self::selector("span.mbrate")?; + let views_sel = Self::selector("span.mbvie")?; + let uploader_sel = Self::selector("span.mb-uploader a[href]")?; + + let mut items = Vec::new(); + + for card in document.select(&card_sel) { + let id = match card.value().attr("data-id") { + Some(v) if !v.is_empty() => v.to_string(), + _ => continue, + }; + + let link = match card.select(&link_sel).next() { + Some(el) => el, + None => continue, + }; + let href = link.value().attr("href").unwrap_or_default(); + let page_url = Self::normalize_url(href); + if page_url.is_empty() { + continue; + } + + let title = link + .value() + .attr("title") + .map(Self::decode_html) + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| Self::text_of(&link)); + if title.is_empty() { + continue; + } + + let thumb = card + .select(&img_sel) + .next() + .and_then(|el| el.value().attr("src").or_else(|| el.value().attr("data-src"))) + .map(Self::normalize_url) + .unwrap_or_default(); + + let duration = card + .select(&dur_sel) + .next() + .map(|el| Self::parse_duration(&Self::text_of(&el))) + .unwrap_or(0); + + let rating = card + .select(&rate_sel) + .next() + .and_then(|el| Self::parse_rating_pct(&Self::text_of(&el))); + + let views = card + .select(&views_sel) + .next() + .and_then(|el| Self::parse_views(&Self::text_of(&el))); + + let uploader_el = card.select(&uploader_sel).next(); + let uploader_name = uploader_el.as_ref().map(|el| Self::text_of(el)); + let uploader_url = uploader_el + .and_then(|el| el.value().attr("href").map(Self::normalize_url)); + + let mut item = VideoItem::new( + id, + title.trim().to_string(), + page_url, + CHANNEL_ID.to_string(), + thumb, + duration, + ); + if let Some(r) = rating { + item.rating = Some(r); + } + if let Some(v) = views { + item.views = Some(v); + } + if let Some(name) = uploader_name.filter(|n| !n.is_empty()) { + item.uploader = Some(name); + } + if let Some(url) = uploader_url.filter(|u| !u.is_empty()) { + let uploader_id = url + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + if !uploader_id.is_empty() { + item.uploaderId = Some(format!("{CHANNEL_ID}:{uploader_id}")); + } + item.uploaderUrl = Some(url); + } + + items.push(item); + } + + Ok(items) + } + + async fn load_pornstars(pornstar_map: Arc>>) -> Result<()> { + let mut requester = Requester::new(); + let url = format!("{BASE_URL}/pornstar-list/"); + let html = Self::fetch_html(&mut requester, &url).await?; + let document = Html::parse_document(&html); + let sel = Self::selector("a[href*=\"/pornstar/\"]")?; + let prefix = format!("{BASE_URL}/pornstar/"); + + for el in document.select(&sel) { + let href = el.value().attr("href").unwrap_or_default(); + let full = Self::normalize_url(href); + if !full.starts_with(&prefix) { + continue; + } + let slug = full + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + if slug.is_empty() { + continue; + } + let name = el + .value() + .attr("title") + .map(Self::decode_html) + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| Self::text_of(&el)); + if name.is_empty() { + continue; + } + let canonical = format!("{BASE_URL}/pornstar/{slug}"); + if let Ok(mut map) = pornstar_map.write() { + map.insert(Self::normalize_key(&name), canonical.clone()); + map.insert(Self::normalize_key(&slug), canonical); + } + } + Ok(()) + } + + fn lookup_category(query: &str) -> Option { + let normalized = Self::normalize_key(query); + for (slug, label) in CATEGORIES { + if Self::normalize_key(label) == normalized || Self::normalize_key(slug) == normalized { + return Some(format!("{BASE_URL}/cat/{slug}")); + } + } + None + } + + fn resolve_query_target(&self, query: &str) -> Target { + let trimmed = query.trim().trim_start_matches('@'); + + if let Some((kind, value)) = trimmed.split_once(':') { + let value = value.trim().replace(' ', "-").to_ascii_lowercase(); + if !value.is_empty() { + match kind.trim().to_ascii_lowercase().as_str() { + "cat" | "category" => { + return Target::Archive(format!("{BASE_URL}/cat/{value}")); + } + "tag" => { + return Target::Archive(format!("{BASE_URL}/tag/{value}")); + } + "pornstar" | "star" => { + return Target::Archive(format!("{BASE_URL}/pornstar/{value}")); + } + "uploader" | "profile" => { + return Target::Archive(format!("{BASE_URL}/profile/{value}")); + } + _ => {} + } + } + } + + // Check category name + if let Some(url) = Self::lookup_category(trimmed) { + return Target::Archive(url); + } + + // Check pornstar map + let normalized = Self::normalize_key(trimmed); + if let Some(url) = self + .pornstar_map + .read() + .ok() + .and_then(|m| m.get(&normalized).cloned()) + { + return Target::Archive(url); + } + + Target::Search(trimmed.to_string()) + } + + fn resolve_sort_target(sort: &str) -> Target { + match sort.trim().to_ascii_lowercase().as_str() { + "popular" | "viewed" | "most_viewed" => Target::MostViewed, + "rated" | "rating" | "top" => Target::TopRated, + "best" => Target::BestVideos, + _ => Target::Latest, + } + } + + fn resolve_option_target(&self, options: &ServerOptions, sort: &str) -> Target { + if let Some(cat) = options.categories.as_deref() { + if cat != "all" && !cat.is_empty() { + let url = if cat.starts_with("http") { + cat.to_string() + } else { + format!("{BASE_URL}/cat/{cat}") + }; + return Target::Archive(url); + } + } + Self::resolve_sort_target(sort) + } + + async fn fetch_target( + &self, + cache: VideoCache, + target: Target, + page: u16, + per_page: usize, + options: ServerOptions, + ) -> Result> { + let url = Self::target_url(&target, page); + let cache_key = format!("{url}#per={per_page}"); + + if let Some((ts, cached)) = cache.get(&cache_key) { + if ts.elapsed().unwrap_or_default().as_secs() < 300 { + return Ok(cached.clone()); + } + } + + let mut requester = + requester_or_default(&options, CHANNEL_ID, "eporner.fetch_target.missing_requester"); + let html = match Self::fetch_html(&mut requester, &url).await { + Ok(v) => v, + Err(e) => { + report_provider_error( + CHANNEL_ID, + "fetch_target.request", + &format!("url={url}; error={e}"), + ) + .await; + return Ok(vec![]); + } + }; + + if html.trim().is_empty() { + report_provider_error( + CHANNEL_ID, + "fetch_target.empty", + &format!("url={url}"), + ) + .await; + return Ok(vec![]); + } + + let items = self.parse_list_page_limited(&html, per_page)?; + if !items.is_empty() { + cache.insert(cache_key, items.clone()); + } + Ok(items) + } + + fn parse_list_page_limited(&self, html: &str, limit: usize) -> Result> { + let all = Self::parse_list_page(html)?; + Ok(all.into_iter().take(limit.max(1)).collect()) + } +} + +#[async_trait] +impl Provider for EpornerProvider { + 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).max(1); + let per_page = per_page.parse::().unwrap_or(10).clamp(1, 60); + + let target = match query { + Some(q) if !q.trim().is_empty() => self.resolve_query_target(q.trim()), + _ => self.resolve_option_target(&options, &sort), + }; + + match self.fetch_target(cache, target, page, per_page, options).await { + Ok(items) => items, + Err(e) => { + report_provider_error( + CHANNEL_ID, + "get_videos", + &format!("sort={sort}; page={page}; error={e}"), + ) + .await; + vec![] + } + } + } + + fn get_channel(&self, cv: ClientVersion) -> Option { + Some(self.build_channel(cv)) + } +}