1381 lines
45 KiB
Rust
1381 lines
45 KiB
Rust
use crate::DbPool;
|
|
use crate::api::ClientVersion;
|
|
use crate::providers::{
|
|
Provider, build_proxy_url, report_provider_error, report_provider_error_background,
|
|
requester_or_default, strip_url_scheme,
|
|
};
|
|
use crate::status::*;
|
|
use crate::util::cache::VideoCache;
|
|
use crate::videos::{ServerOptions, VideoEmbed, VideoFormat, VideoItem};
|
|
use async_trait::async_trait;
|
|
use chrono::{NaiveDate, TimeZone, Utc};
|
|
use error_chain::error_chain;
|
|
use futures::stream::{self, StreamExt};
|
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
|
use regex::Regex;
|
|
use scraper::{ElementRef, Html, Selector};
|
|
use serde_json::Value;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::{Arc, RwLock};
|
|
use std::{thread, vec};
|
|
use url::Url;
|
|
use wreq::Version;
|
|
|
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
|
crate::providers::ProviderChannelMetadata {
|
|
group_id: "studio-network",
|
|
tags: &["premium", "studio", "aggregator"],
|
|
};
|
|
|
|
error_chain! {
|
|
foreign_links {
|
|
Io(std::io::Error);
|
|
Json(serde_json::Error);
|
|
}
|
|
errors {
|
|
Parse(msg: String) {
|
|
description("parse error")
|
|
display("parse error: {}", msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
const BASE_URL: &str = "https://www.pornhd3x.tv";
|
|
const CHANNEL_ID: &str = "pornhd3x";
|
|
const HOME_ARCHIVE_PATH: &str = "/premium-porn-hd";
|
|
const SOURCE_SECRET: &str = "98126avrbi6m49vd7shxkn985";
|
|
const SOURCE_COOKIE_PREFIX: &str = "826avrbi6m49vd7shxkn985m";
|
|
const SOURCE_COOKIE_SUFFIX: &str = "k06twz87wwxtp3dqiicks2df";
|
|
const RECENT_TAG_DETAIL_LIMIT: usize = 8;
|
|
const DETAIL_CONCURRENCY: usize = 4;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Pornhd3xProvider {
|
|
url: String,
|
|
categories: Arc<RwLock<Vec<FilterOption>>>,
|
|
tags: Arc<RwLock<Vec<FilterOption>>>,
|
|
stars: Arc<RwLock<Vec<FilterOption>>>,
|
|
uploaders: Arc<RwLock<Vec<FilterOption>>>,
|
|
source_counter: Arc<AtomicU32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Target {
|
|
Latest,
|
|
Search(String),
|
|
Archive(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ListStub {
|
|
slug: String,
|
|
detail_url: String,
|
|
title: String,
|
|
thumb: String,
|
|
preview: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct DetailMetadata {
|
|
title: Option<String>,
|
|
description: Option<String>,
|
|
thumb: Option<String>,
|
|
studio: Option<(String, String)>,
|
|
categories: Vec<(String, String)>,
|
|
actors: Vec<(String, String)>,
|
|
tags: Vec<(String, String)>,
|
|
uploaded_at: Option<u64>,
|
|
episode_id: Option<String>,
|
|
}
|
|
|
|
impl Pornhd3xProvider {
|
|
pub fn new() -> Self {
|
|
let provider = Self {
|
|
url: BASE_URL.to_string(),
|
|
categories: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
tags: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
stars: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
uploaders: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
source_counter: Arc::new(AtomicU32::new(0)),
|
|
};
|
|
provider.spawn_initial_load();
|
|
provider
|
|
}
|
|
|
|
fn spawn_initial_load(&self) {
|
|
let provider = self.clone();
|
|
thread::spawn(move || {
|
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(runtime) => runtime,
|
|
Err(error) => {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"spawn_initial_load.runtime_build",
|
|
&error.to_string(),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
runtime.block_on(async move {
|
|
provider.refresh_filter_catalogs().await;
|
|
});
|
|
});
|
|
}
|
|
|
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
|
let categories = self
|
|
.categories
|
|
.read()
|
|
.map(|value| value.clone())
|
|
.unwrap_or_default();
|
|
let tags = self
|
|
.tags
|
|
.read()
|
|
.map(|value| value.clone())
|
|
.unwrap_or_default();
|
|
let stars = self
|
|
.stars
|
|
.read()
|
|
.map(|value| value.clone())
|
|
.unwrap_or_default();
|
|
let uploaders = self
|
|
.uploaders
|
|
.read()
|
|
.map(|value| value.clone())
|
|
.unwrap_or_default();
|
|
|
|
Channel {
|
|
id: CHANNEL_ID.to_string(),
|
|
name: "PornHD3X".to_string(),
|
|
description:
|
|
"Premium studio archive pages with studio, category, pornstar, and keyword filters."
|
|
.to_string(),
|
|
premium: false,
|
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.pornhd3x.tv"
|
|
.to_string(),
|
|
status: "active".to_string(),
|
|
categories: categories.iter().map(|value| value.title.clone()).collect(),
|
|
options: vec![
|
|
ChannelOption {
|
|
id: "sort".to_string(),
|
|
title: "Sort".to_string(),
|
|
description:
|
|
"Latest archive only. Extra top lists on the site currently error server-side."
|
|
.to_string(),
|
|
systemImage: "list.number".to_string(),
|
|
colorName: "blue".to_string(),
|
|
options: vec![FilterOption {
|
|
id: "new".to_string(),
|
|
title: "Latest".to_string(),
|
|
}],
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "sites".to_string(),
|
|
title: "Uploaders".to_string(),
|
|
description: "Browse studio archives directly.".to_string(),
|
|
systemImage: "person.crop.square".to_string(),
|
|
colorName: "purple".to_string(),
|
|
options: uploaders,
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "categories".to_string(),
|
|
title: "Categories".to_string(),
|
|
description: "Browse a category archive.".to_string(),
|
|
systemImage: "square.grid.2x2".to_string(),
|
|
colorName: "orange".to_string(),
|
|
options: categories,
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "stars".to_string(),
|
|
title: "Pornstars".to_string(),
|
|
description: "Browse a pornstar archive.".to_string(),
|
|
systemImage: "star.fill".to_string(),
|
|
colorName: "yellow".to_string(),
|
|
options: stars,
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "filter".to_string(),
|
|
title: "Tags".to_string(),
|
|
description: "Browse discovered keyword archives.".to_string(),
|
|
systemImage: "tag.fill".to_string(),
|
|
colorName: "green".to_string(),
|
|
options: tags,
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
nsfw: true,
|
|
cacheDuration: Some(1800),
|
|
}
|
|
}
|
|
|
|
fn selector(value: &str) -> Result<Selector> {
|
|
Selector::parse(value)
|
|
.map_err(|error| Error::from(format!("selector `{value}` parse failed: {error}")))
|
|
}
|
|
|
|
fn regex(value: &str) -> Result<Regex> {
|
|
Regex::new(value).map_err(|error| Error::from(format!("regex `{value}` failed: {error}")))
|
|
}
|
|
|
|
fn collapse_whitespace(text: &str) -> String {
|
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
fn decode_html(text: &str) -> String {
|
|
decode(text.as_bytes())
|
|
.to_string()
|
|
.unwrap_or_else(|_| text.to_string())
|
|
}
|
|
|
|
fn text_of(element: &ElementRef<'_>) -> String {
|
|
Self::decode_html(&Self::collapse_whitespace(
|
|
&element.text().collect::<Vec<_>>().join(" "),
|
|
))
|
|
}
|
|
|
|
fn titleize_slug(slug: &str) -> String {
|
|
slug.split(['-', '_'])
|
|
.filter(|part| !part.is_empty())
|
|
.map(|part| {
|
|
let mut chars = part.chars();
|
|
match chars.next() {
|
|
Some(first) => {
|
|
let mut word = first.to_uppercase().collect::<String>();
|
|
word.push_str(chars.as_str());
|
|
word
|
|
}
|
|
None => String::new(),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn normalize_title(value: &str) -> String {
|
|
value
|
|
.to_ascii_lowercase()
|
|
.replace('&', " and ")
|
|
.chars()
|
|
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
|
|
.collect::<String>()
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn push_unique(target: &Arc<RwLock<Vec<FilterOption>>>, item: FilterOption) {
|
|
if item.id.is_empty() || item.title.is_empty() {
|
|
return;
|
|
}
|
|
if let Ok(mut values) = target.write() {
|
|
if !values.iter().any(|value| value.id == item.id) {
|
|
values.push(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_url(&self, raw: &str) -> String {
|
|
let value = raw.trim();
|
|
if value.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
if value.starts_with("//") {
|
|
return format!("https:{value}");
|
|
}
|
|
|
|
if let Ok(url) = Url::parse(value) {
|
|
if let Some(host) = url.host_str() {
|
|
if host.contains("pornhd3x.tv") || host.contains("brazzers3x.") {
|
|
return format!(
|
|
"{}{}{}",
|
|
self.url,
|
|
url.path(),
|
|
url.query()
|
|
.map(|query| format!("?{query}"))
|
|
.unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
if value.starts_with("http://") {
|
|
return value.replacen("http://", "https://", 1);
|
|
}
|
|
return value.to_string();
|
|
}
|
|
|
|
if value.starts_with('/') {
|
|
return format!("{}{}", self.url, value);
|
|
}
|
|
|
|
format!("{}/{}", self.url.trim_end_matches('/'), value)
|
|
}
|
|
|
|
fn sanitize_search_query(query: &str) -> String {
|
|
query
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '-' {
|
|
ch
|
|
} else {
|
|
' '
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn build_home_url(&self, page: u32) -> String {
|
|
if page > 1 {
|
|
format!("{}{}/page-{}", self.url, HOME_ARCHIVE_PATH, page)
|
|
} else {
|
|
format!("{}{}", self.url, HOME_ARCHIVE_PATH)
|
|
}
|
|
}
|
|
|
|
fn build_search_url(&self, query: &str, page: u32) -> String {
|
|
let query = Self::sanitize_search_query(query).replace(' ', "%20");
|
|
if page > 1 {
|
|
format!("{}/search/{}/page-{}", self.url, query, page)
|
|
} else {
|
|
format!("{}/search/{}", self.url, query)
|
|
}
|
|
}
|
|
|
|
fn build_archive_page_url(base: &str, page: u32) -> String {
|
|
let base = base.trim_end_matches('/');
|
|
if page > 1 {
|
|
format!("{base}/page-{page}")
|
|
} else {
|
|
base.to_string()
|
|
}
|
|
}
|
|
|
|
fn is_allowed_archive_url(&self, value: &str) -> bool {
|
|
let normalized = self.normalize_url(value);
|
|
normalized.starts_with(&self.url)
|
|
&& [
|
|
"/studio/",
|
|
"/category/",
|
|
"/tag/",
|
|
"/pornstar/",
|
|
HOME_ARCHIVE_PATH,
|
|
]
|
|
.iter()
|
|
.any(|prefix| normalized.contains(prefix))
|
|
}
|
|
|
|
fn match_filter(options: &[FilterOption], query: &str) -> Option<String> {
|
|
let normalized_query = Self::normalize_title(query);
|
|
options
|
|
.iter()
|
|
.find(|value| {
|
|
value.id != "all" && Self::normalize_title(&value.title) == normalized_query
|
|
})
|
|
.map(|value| value.id.clone())
|
|
}
|
|
|
|
fn is_allowed_detail_url(&self, value: &str) -> bool {
|
|
let normalized = self.normalize_url(value);
|
|
let Some(url) = Url::parse(&normalized).ok() else {
|
|
return false;
|
|
};
|
|
if url.scheme() != "https" {
|
|
return false;
|
|
}
|
|
let Some(host) = url.host_str() else {
|
|
return false;
|
|
};
|
|
(host == "www.pornhd3x.tv" || host == "pornhd3x.tv") && url.path().starts_with("/movies/")
|
|
}
|
|
|
|
fn proxied_video(
|
|
&self,
|
|
options: &ServerOptions,
|
|
detail_url: &str,
|
|
quality: Option<&str>,
|
|
) -> String {
|
|
if detail_url.is_empty() || !self.is_allowed_detail_url(detail_url) {
|
|
return detail_url.to_string();
|
|
}
|
|
|
|
let mut target = strip_url_scheme(detail_url);
|
|
if let Some(quality) = quality.map(str::trim).filter(|quality| !quality.is_empty()) {
|
|
target.push_str("/__quality__/");
|
|
target.push_str(&quality.replace(' ', "%20"));
|
|
}
|
|
|
|
build_proxy_url(options, CHANNEL_ID, &target)
|
|
}
|
|
|
|
fn filters_need_refresh(&self) -> bool {
|
|
let categories_len = self
|
|
.categories
|
|
.read()
|
|
.map(|values| values.len())
|
|
.unwrap_or_default();
|
|
let tags_len = self
|
|
.tags
|
|
.read()
|
|
.map(|values| values.len())
|
|
.unwrap_or_default();
|
|
let stars_len = self
|
|
.stars
|
|
.read()
|
|
.map(|values| values.len())
|
|
.unwrap_or_default();
|
|
let uploaders_len = self
|
|
.uploaders
|
|
.read()
|
|
.map(|values| values.len())
|
|
.unwrap_or_default();
|
|
|
|
categories_len <= 1 || tags_len <= 1 || stars_len <= 1 || uploaders_len <= 1
|
|
}
|
|
|
|
async fn refresh_filter_catalogs(&self) {
|
|
if let Err(error) = self.load_home_catalogs().await {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"refresh_filter_catalogs.home",
|
|
&error.to_string(),
|
|
);
|
|
}
|
|
if let Err(error) = self.load_sitemap_catalogs().await {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"refresh_filter_catalogs.sitemap",
|
|
&error.to_string(),
|
|
);
|
|
}
|
|
if let Err(error) = self.load_recent_tags().await {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"refresh_filter_catalogs.recent_tags",
|
|
&error.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn ensure_catalogs(&self) {
|
|
if self.filters_need_refresh() {
|
|
self.refresh_filter_catalogs().await;
|
|
}
|
|
}
|
|
|
|
async fn load_home_catalogs(&self) -> Result<()> {
|
|
let mut requester = requester_or_default(
|
|
&ServerOptions {
|
|
featured: None,
|
|
category: None,
|
|
sites: None,
|
|
filter: None,
|
|
language: None,
|
|
public_url_base: None,
|
|
requester: None,
|
|
network: None,
|
|
stars: None,
|
|
categories: None,
|
|
duration: None,
|
|
sort: None,
|
|
sexuality: None,
|
|
},
|
|
CHANNEL_ID,
|
|
"load_home_catalogs.requester",
|
|
);
|
|
let html = requester
|
|
.get(BASE_URL, Some(Version::HTTP_11))
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
let document = Html::parse_document(&html);
|
|
|
|
let category_selector = Self::selector("#menu a.ml-item[href^=\"/category/\"]")?;
|
|
let studio_selector = Self::selector("#menu a.ml-item[href^=\"/studio/\"]")?;
|
|
|
|
for element in document.select(&category_selector) {
|
|
let Some(href) = element.value().attr("href") else {
|
|
continue;
|
|
};
|
|
let title = Self::text_of(&element);
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
Self::push_unique(
|
|
&self.categories,
|
|
FilterOption {
|
|
id: self.normalize_url(href),
|
|
title,
|
|
},
|
|
);
|
|
}
|
|
|
|
for element in document.select(&studio_selector) {
|
|
let Some(href) = element.value().attr("href") else {
|
|
continue;
|
|
};
|
|
let title = Self::text_of(&element);
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
Self::push_unique(
|
|
&self.uploaders,
|
|
FilterOption {
|
|
id: self.normalize_url(href),
|
|
title,
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_sitemap_catalogs(&self) -> Result<()> {
|
|
let mut requester = requester_or_default(
|
|
&ServerOptions {
|
|
featured: None,
|
|
category: None,
|
|
sites: None,
|
|
filter: None,
|
|
language: None,
|
|
public_url_base: None,
|
|
requester: None,
|
|
network: None,
|
|
stars: None,
|
|
categories: None,
|
|
duration: None,
|
|
sort: None,
|
|
sexuality: None,
|
|
},
|
|
CHANNEL_ID,
|
|
"load_sitemap_catalogs.requester",
|
|
);
|
|
let xml = requester
|
|
.get(&format!("{}/sitemap.xml", self.url), Some(Version::HTTP_11))
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
let loc_regex = Self::regex(r"<loc>([^<]+)</loc>")?;
|
|
|
|
for captures in loc_regex.captures_iter(&xml) {
|
|
let Some(raw_url) = captures.get(1).map(|value| value.as_str()) else {
|
|
continue;
|
|
};
|
|
let normalized = self.normalize_url(raw_url);
|
|
let Some(parsed) = Url::parse(&normalized).ok() else {
|
|
continue;
|
|
};
|
|
let path = parsed.path().trim_end_matches('/');
|
|
|
|
if let Some(slug) = path.strip_prefix("/studio/") {
|
|
Self::push_unique(
|
|
&self.uploaders,
|
|
FilterOption {
|
|
id: normalized.clone(),
|
|
title: Self::titleize_slug(slug),
|
|
},
|
|
);
|
|
} else if let Some(slug) = path.strip_prefix("/pornstar/") {
|
|
Self::push_unique(
|
|
&self.stars,
|
|
FilterOption {
|
|
id: normalized.clone(),
|
|
title: Self::titleize_slug(slug),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_recent_tags(&self) -> Result<()> {
|
|
let mut requester = requester_or_default(
|
|
&ServerOptions {
|
|
featured: None,
|
|
category: None,
|
|
sites: None,
|
|
filter: None,
|
|
language: None,
|
|
public_url_base: None,
|
|
requester: None,
|
|
network: None,
|
|
stars: None,
|
|
categories: None,
|
|
duration: None,
|
|
sort: None,
|
|
sexuality: None,
|
|
},
|
|
CHANNEL_ID,
|
|
"load_recent_tags.requester",
|
|
);
|
|
let html = requester
|
|
.get(&self.build_home_url(1), Some(Version::HTTP_11))
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
let stubs = self.parse_list_items(&html)?;
|
|
for stub in stubs.into_iter().take(RECENT_TAG_DETAIL_LIMIT) {
|
|
let detail_html = requester
|
|
.get(&stub.detail_url, Some(Version::HTTP_11))
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
if let Ok(metadata) = self.parse_detail_metadata(&detail_html) {
|
|
self.store_detail_filters(&metadata);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_option_target(&self, options: &ServerOptions) -> Target {
|
|
if let Some(value) = options.sites.as_deref() {
|
|
if value != "all" && self.is_allowed_archive_url(value) {
|
|
return Target::Archive(self.normalize_url(value));
|
|
}
|
|
}
|
|
if let Some(value) = options.stars.as_deref() {
|
|
if value != "all" && self.is_allowed_archive_url(value) {
|
|
return Target::Archive(self.normalize_url(value));
|
|
}
|
|
}
|
|
if let Some(value) = options.filter.as_deref() {
|
|
if value != "all" && self.is_allowed_archive_url(value) {
|
|
return Target::Archive(self.normalize_url(value));
|
|
}
|
|
}
|
|
if let Some(value) = options.categories.as_deref() {
|
|
if value != "all" && self.is_allowed_archive_url(value) {
|
|
return Target::Archive(self.normalize_url(value));
|
|
}
|
|
}
|
|
Target::Latest
|
|
}
|
|
|
|
fn resolve_query_target(&self, query: &str) -> Target {
|
|
if let Ok(uploaders) = self.uploaders.read() {
|
|
if let Some(target) = Self::match_filter(&uploaders, query) {
|
|
return Target::Archive(target);
|
|
}
|
|
}
|
|
if let Ok(stars) = self.stars.read() {
|
|
if let Some(target) = Self::match_filter(&stars, query) {
|
|
return Target::Archive(target);
|
|
}
|
|
}
|
|
if let Ok(tags) = self.tags.read() {
|
|
if let Some(target) = Self::match_filter(&tags, query) {
|
|
return Target::Archive(target);
|
|
}
|
|
}
|
|
if let Ok(categories) = self.categories.read() {
|
|
if let Some(target) = Self::match_filter(&categories, query) {
|
|
return Target::Archive(target);
|
|
}
|
|
}
|
|
Target::Search(query.to_string())
|
|
}
|
|
|
|
fn target_url(&self, target: &Target, page: u32) -> String {
|
|
match target {
|
|
Target::Latest => self.build_home_url(page),
|
|
Target::Search(query) => self.build_search_url(query, page),
|
|
Target::Archive(url) => Self::build_archive_page_url(url, page),
|
|
}
|
|
}
|
|
|
|
fn parse_list_items(&self, html: &str) -> Result<Vec<ListStub>> {
|
|
let document = Html::parse_document(html);
|
|
let item_selector = Self::selector(".movies-list .ml-item")?;
|
|
let anchor_selector = Self::selector("a.ml-mask[href]")?;
|
|
let title_selector = Self::selector(".mli-info h2")?;
|
|
let img_selector = Self::selector("img[data-original], img[src]")?;
|
|
let preview_holder_selector = Self::selector(".thumb__img[data-preview]")?;
|
|
let preview_icon_selector =
|
|
Self::selector("span.player_icon[str], span.player_icon1[str]")?;
|
|
|
|
let mut items = Vec::new();
|
|
|
|
for element in document.select(&item_selector) {
|
|
let Some(anchor) = element.select(&anchor_selector).next() else {
|
|
continue;
|
|
};
|
|
let Some(href) = anchor.value().attr("href") else {
|
|
continue;
|
|
};
|
|
|
|
let detail_url = self.normalize_url(href);
|
|
if detail_url.is_empty() || !detail_url.contains("/movies/") {
|
|
continue;
|
|
}
|
|
|
|
let title = element
|
|
.select(&title_selector)
|
|
.next()
|
|
.map(|value| Self::text_of(&value))
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| anchor.value().attr("title").map(Self::decode_html))
|
|
.unwrap_or_default();
|
|
if title.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let thumb = element
|
|
.select(&img_selector)
|
|
.next()
|
|
.and_then(|value| {
|
|
value
|
|
.value()
|
|
.attr("data-original")
|
|
.or_else(|| value.value().attr("src"))
|
|
})
|
|
.map(|value| self.normalize_url(value))
|
|
.unwrap_or_default();
|
|
|
|
let preview = element
|
|
.select(&preview_holder_selector)
|
|
.next()
|
|
.and_then(|value| value.value().attr("data-preview"))
|
|
.or_else(|| {
|
|
element
|
|
.select(&preview_icon_selector)
|
|
.next()
|
|
.and_then(|value| value.value().attr("str"))
|
|
})
|
|
.map(|value| self.normalize_url(value))
|
|
.filter(|value| !value.is_empty());
|
|
|
|
let slug = detail_url
|
|
.trim_end_matches('/')
|
|
.rsplit('/')
|
|
.next()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
if slug.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
items.push(ListStub {
|
|
slug,
|
|
detail_url,
|
|
title,
|
|
thumb,
|
|
preview,
|
|
});
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
fn parse_uploaded_at(title: &str) -> Option<u64> {
|
|
let patterns = [
|
|
r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{4})",
|
|
r"(?P<day>\d{1,2})-(?P<month>\d{1,2})-(?P<year>\d{4})",
|
|
r"\((?P<day>\d{1,2})-(?P<month>\d{1,2})-(?P<year>\d{4})\)",
|
|
];
|
|
|
|
for pattern in patterns {
|
|
let Ok(regex) = Regex::new(pattern) else {
|
|
continue;
|
|
};
|
|
let Some(captures) = regex.captures(title) else {
|
|
continue;
|
|
};
|
|
let day = captures.name("day")?.as_str().parse::<u32>().ok()?;
|
|
let month = captures.name("month")?.as_str().parse::<u32>().ok()?;
|
|
let year = captures.name("year")?.as_str().parse::<i32>().ok()?;
|
|
let date = NaiveDate::from_ymd_opt(year, month, day)?;
|
|
let datetime = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?);
|
|
return Some(datetime.timestamp() as u64);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn parse_detail_metadata(&self, html: &str) -> Result<DetailMetadata> {
|
|
let document = Html::parse_document(html);
|
|
let title_selector = Self::selector(".mvic-desc h3")?;
|
|
let description_selector = Self::selector("meta[name=\"description\"]")?;
|
|
let og_image_selector = Self::selector("meta[property=\"og:image\"]")?;
|
|
let studio_selector = Self::selector("#bread a[href*=\"/studio/\"]")?;
|
|
let category_selector = Self::selector(".mvici-left a[href*=\"/category/\"]")?;
|
|
let actor_selector = Self::selector(".mvici-left a[href*=\"/pornstar/\"]")?;
|
|
let tag_selector = Self::selector("#mv-keywords a[href*=\"/tag/\"]")?;
|
|
let episode_selector = Self::selector("#uuid, a.btn-eps[episode-id]")?;
|
|
|
|
let title = document
|
|
.select(&title_selector)
|
|
.next()
|
|
.map(|value| Self::text_of(&value))
|
|
.filter(|value| !value.is_empty());
|
|
|
|
let description = document
|
|
.select(&description_selector)
|
|
.next()
|
|
.and_then(|value| value.value().attr("content"))
|
|
.map(Self::decode_html)
|
|
.filter(|value| !value.is_empty());
|
|
|
|
let thumb = document
|
|
.select(&og_image_selector)
|
|
.next()
|
|
.and_then(|value| value.value().attr("content"))
|
|
.map(|value| self.normalize_url(value))
|
|
.filter(|value| !value.is_empty());
|
|
|
|
let studio = document.select(&studio_selector).next().and_then(|value| {
|
|
let href = value.value().attr("href")?;
|
|
let title = Self::text_of(&value);
|
|
(!title.is_empty()).then_some((self.normalize_url(href), title))
|
|
});
|
|
|
|
let categories = document
|
|
.select(&category_selector)
|
|
.filter_map(|value| {
|
|
let href = value.value().attr("href")?;
|
|
let title = Self::text_of(&value);
|
|
(!title.is_empty()).then_some((self.normalize_url(href), title))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let actors = document
|
|
.select(&actor_selector)
|
|
.filter_map(|value| {
|
|
let href = value.value().attr("href")?;
|
|
let title = Self::text_of(&value);
|
|
(!title.is_empty()).then_some((self.normalize_url(href), title))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let tags = document
|
|
.select(&tag_selector)
|
|
.filter_map(|value| {
|
|
let href = value.value().attr("href")?;
|
|
let title = Self::text_of(&value);
|
|
(!title.is_empty()).then_some((self.normalize_url(href), title))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let episode_id = document
|
|
.select(&episode_selector)
|
|
.find_map(|value| {
|
|
value
|
|
.value()
|
|
.attr("value")
|
|
.or_else(|| value.value().attr("episode-id"))
|
|
})
|
|
.map(ToOwned::to_owned);
|
|
|
|
Ok(DetailMetadata {
|
|
uploaded_at: title.as_deref().and_then(Self::parse_uploaded_at),
|
|
title,
|
|
description,
|
|
thumb,
|
|
studio,
|
|
categories,
|
|
actors,
|
|
tags,
|
|
episode_id,
|
|
})
|
|
}
|
|
|
|
fn store_detail_filters(&self, metadata: &DetailMetadata) {
|
|
if let Some((url, title)) = &metadata.studio {
|
|
Self::push_unique(
|
|
&self.uploaders,
|
|
FilterOption {
|
|
id: url.clone(),
|
|
title: title.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (url, title) in &metadata.categories {
|
|
Self::push_unique(
|
|
&self.categories,
|
|
FilterOption {
|
|
id: url.clone(),
|
|
title: title.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (url, title) in &metadata.actors {
|
|
Self::push_unique(
|
|
&self.stars,
|
|
FilterOption {
|
|
id: url.clone(),
|
|
title: title.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (url, title) in &metadata.tags {
|
|
Self::push_unique(
|
|
&self.tags,
|
|
FilterOption {
|
|
id: url.clone(),
|
|
title: title.clone(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn build_source_cookie_name(episode_id: &str) -> String {
|
|
format!("{SOURCE_COOKIE_PREFIX}{episode_id}{SOURCE_COOKIE_SUFFIX}")
|
|
}
|
|
|
|
fn build_source_hash(episode_id: &str, nonce: &str) -> String {
|
|
format!(
|
|
"{:x}",
|
|
md5::compute(format!("{episode_id}{nonce}{SOURCE_SECRET}"))
|
|
)
|
|
}
|
|
|
|
fn next_source_request(&self) -> (u32, String) {
|
|
let count = self.source_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
|
let nonce = format!("{:06x}", count % 0xFF_FFFF);
|
|
(count, nonce)
|
|
}
|
|
|
|
async fn fetch_sources(
|
|
&self,
|
|
requester: &mut crate::util::requester::Requester,
|
|
referer: &str,
|
|
episode_id: &str,
|
|
) -> Result<Value> {
|
|
let (count, nonce) = self.next_source_request();
|
|
let source_url = format!(
|
|
"{}/ajax/get_sources/{}/{hash}?count={count}&mobile=true",
|
|
self.url,
|
|
episode_id,
|
|
hash = Self::build_source_hash(episode_id, &nonce),
|
|
count = count,
|
|
);
|
|
let existing_cookie = requester.cookie_header_for_url(&source_url);
|
|
let cookie_name = Self::build_source_cookie_name(episode_id);
|
|
let cookie_value = format!("{cookie_name}={nonce}");
|
|
let combined_cookie = match existing_cookie {
|
|
Some(existing) if !existing.trim().is_empty() => format!("{existing}; {cookie_value}"),
|
|
_ => cookie_value,
|
|
};
|
|
|
|
let response = requester
|
|
.get_with_headers(
|
|
&source_url,
|
|
vec![
|
|
("Cookie".to_string(), combined_cookie),
|
|
("Referer".to_string(), referer.to_string()),
|
|
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
|
(
|
|
"Accept".to_string(),
|
|
"application/json, text/javascript, */*; q=0.01".to_string(),
|
|
),
|
|
],
|
|
Some(Version::HTTP_11),
|
|
)
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
|
|
if response.trim().is_empty() {
|
|
return Err(Error::from("source payload empty"));
|
|
}
|
|
|
|
Ok(serde_json::from_str::<Value>(&response)?)
|
|
}
|
|
|
|
fn build_formats(
|
|
&self,
|
|
value: &Value,
|
|
options: &ServerOptions,
|
|
detail_url: &str,
|
|
) -> Vec<VideoFormat> {
|
|
let mut formats = Vec::new();
|
|
for playlist in value
|
|
.get("playlist")
|
|
.and_then(|playlist| playlist.as_array())
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
for source in playlist
|
|
.get("sources")
|
|
.and_then(|sources| sources.as_array())
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let Some(file) = source.get("file").and_then(|file| file.as_str()) else {
|
|
continue;
|
|
};
|
|
let url = self.normalize_url(file);
|
|
if url.is_empty() {
|
|
continue;
|
|
}
|
|
let quality = source
|
|
.get("label")
|
|
.and_then(|label| label.as_str())
|
|
.unwrap_or("HLS")
|
|
.to_string();
|
|
let format_name = if url.contains(".m3u8") { "hls" } else { "mp4" };
|
|
let format_url = self.proxied_video(options, detail_url, Some(&quality));
|
|
let format = VideoFormat::new(format_url, quality.clone(), format_name.to_string())
|
|
.format_id(quality.to_ascii_lowercase())
|
|
.format_note(quality);
|
|
formats.push(format);
|
|
}
|
|
}
|
|
formats
|
|
}
|
|
|
|
async fn enrich_stub(
|
|
&self,
|
|
stub: ListStub,
|
|
options: &ServerOptions,
|
|
) -> Result<Option<VideoItem>> {
|
|
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_stub.requester");
|
|
let detail_html = requester
|
|
.get(&stub.detail_url, Some(Version::HTTP_11))
|
|
.await
|
|
.map_err(|error| Error::from(error.to_string()))?;
|
|
let metadata = self.parse_detail_metadata(&detail_html)?;
|
|
self.store_detail_filters(&metadata);
|
|
|
|
let Some(episode_id) = metadata.episode_id.clone() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let source_payload = match self
|
|
.fetch_sources(&mut requester, &stub.detail_url, &episode_id)
|
|
.await
|
|
{
|
|
Ok(payload) => payload,
|
|
Err(error) => {
|
|
report_provider_error_background(
|
|
CHANNEL_ID,
|
|
"enrich_stub.fetch_sources",
|
|
&format!("detail_url={}; error={}", stub.detail_url, error),
|
|
);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
let mut formats = self.build_formats(&source_payload, options, &stub.detail_url);
|
|
let proxied_url = self.proxied_video(options, &stub.detail_url, None);
|
|
let direct_url = if !proxied_url.is_empty() {
|
|
Some(proxied_url)
|
|
} else {
|
|
formats
|
|
.first()
|
|
.map(|format| format.url.clone())
|
|
.or_else(|| {
|
|
source_payload
|
|
.get("embed_url")
|
|
.or_else(|| source_payload.get("embedUrl"))
|
|
.and_then(|value| value.as_str())
|
|
.map(|value| self.normalize_url(value))
|
|
})
|
|
};
|
|
|
|
let Some(url) = direct_url else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let title = metadata
|
|
.title
|
|
.clone()
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(stub.title.clone());
|
|
let thumb = metadata
|
|
.thumb
|
|
.clone()
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(stub.thumb.clone());
|
|
|
|
let mut item = VideoItem::new(
|
|
episode_id.clone(),
|
|
title.clone(),
|
|
url.clone(),
|
|
CHANNEL_ID.to_string(),
|
|
thumb,
|
|
0,
|
|
);
|
|
item.preview = stub.preview.clone();
|
|
if let Some((uploader_url, uploader)) = &metadata.studio {
|
|
item.uploader = Some(uploader.clone());
|
|
item.uploaderUrl = Some(uploader_url.clone());
|
|
}
|
|
|
|
let mut item_tags = metadata
|
|
.tags
|
|
.iter()
|
|
.map(|(_, title)| title.clone())
|
|
.collect::<Vec<_>>();
|
|
item_tags.extend(metadata.categories.iter().map(|(_, title)| title.clone()));
|
|
item_tags.extend(metadata.actors.iter().map(|(_, title)| title.clone()));
|
|
item_tags.sort();
|
|
item_tags.dedup();
|
|
if !item_tags.is_empty() {
|
|
item.tags = Some(item_tags);
|
|
}
|
|
|
|
item.uploadedAt = metadata.uploaded_at;
|
|
|
|
if !formats.is_empty() {
|
|
item.formats = Some(std::mem::take(&mut formats));
|
|
}
|
|
|
|
if source_payload
|
|
.get("embed")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false)
|
|
{
|
|
if let Some(embed_url) = source_payload
|
|
.get("embed_url")
|
|
.or_else(|| source_payload.get("embedUrl"))
|
|
.and_then(|value| value.as_str())
|
|
{
|
|
item.embed = Some(VideoEmbed {
|
|
html: format!(
|
|
"<iframe src=\"{}\" width=\"100%\" height=\"500\" frameborder=\"0\" allowfullscreen></iframe>",
|
|
self.normalize_url(embed_url)
|
|
),
|
|
source: self.normalize_url(embed_url),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(Some(item))
|
|
}
|
|
|
|
async fn fetch_items_for_target(
|
|
&self,
|
|
cache: VideoCache,
|
|
target: Target,
|
|
page: u32,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let request_url = self.target_url(&target, page);
|
|
let old_items = match cache.get(&request_url) {
|
|
Some((time, items)) => {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 60 * 30 {
|
|
return Ok(items.clone());
|
|
}
|
|
items.clone()
|
|
}
|
|
None => vec![],
|
|
};
|
|
|
|
let mut requester =
|
|
requester_or_default(&options, CHANNEL_ID, "fetch_items_for_target.list");
|
|
let html = match requester.get(&request_url, Some(Version::HTTP_11)).await {
|
|
Ok(html) => html,
|
|
Err(error) => {
|
|
report_provider_error(
|
|
CHANNEL_ID,
|
|
"fetch_items_for_target.list",
|
|
&format!("url={request_url}; error={error}"),
|
|
)
|
|
.await;
|
|
return Ok(old_items);
|
|
}
|
|
};
|
|
|
|
let stubs = match self.parse_list_items(&html) {
|
|
Ok(stubs) => stubs,
|
|
Err(error) => {
|
|
report_provider_error(
|
|
CHANNEL_ID,
|
|
"fetch_items_for_target.parse_list",
|
|
&format!("url={request_url}; error={error}"),
|
|
)
|
|
.await;
|
|
return Ok(old_items);
|
|
}
|
|
};
|
|
|
|
let videos = stream::iter(stubs.into_iter().map(|stub| {
|
|
let options = options.clone();
|
|
async move { self.enrich_stub(stub, &options).await.ok().flatten() }
|
|
}))
|
|
.buffer_unordered(DETAIL_CONCURRENCY)
|
|
.filter_map(async move |value| value)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
|
|
if !videos.is_empty() {
|
|
cache.remove(&request_url);
|
|
cache.insert(request_url, videos.clone());
|
|
return Ok(videos);
|
|
}
|
|
|
|
Ok(old_items)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for Pornhd3xProvider {
|
|
async fn get_videos(
|
|
&self,
|
|
cache: VideoCache,
|
|
_pool: DbPool,
|
|
_sort: String,
|
|
query: Option<String>,
|
|
page: String,
|
|
_per_page: String,
|
|
options: ServerOptions,
|
|
) -> Vec<VideoItem> {
|
|
self.ensure_catalogs().await;
|
|
|
|
let page = page
|
|
.parse::<u32>()
|
|
.ok()
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(1);
|
|
let target = match query
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
Some(query) => self.resolve_query_target(query),
|
|
None => self.resolve_option_target(&options),
|
|
};
|
|
|
|
match self
|
|
.fetch_items_for_target(cache, target, page, options)
|
|
.await
|
|
{
|
|
Ok(items) => items,
|
|
Err(error) => {
|
|
report_provider_error_background(CHANNEL_ID, "get_videos", &error.to_string());
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
|
Some(self.build_channel(clientversion))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
impl Pornhd3xProvider {
|
|
fn new_for_tests() -> Self {
|
|
Self {
|
|
url: BASE_URL.to_string(),
|
|
categories: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
tags: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
stars: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
uploaders: Arc::new(RwLock::new(vec![FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
}])),
|
|
source_counter: Arc::new(AtomicU32::new(0)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_search_urls_like_live_site() {
|
|
let provider = Pornhd3xProvider::new_for_tests();
|
|
assert_eq!(
|
|
provider.build_search_url("Brazzers", 1),
|
|
"https://www.pornhd3x.tv/search/Brazzers"
|
|
);
|
|
assert_eq!(
|
|
provider.build_search_url("big tits", 3),
|
|
"https://www.pornhd3x.tv/search/big%20tits/page-3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builds_source_cookie_name_and_hash() {
|
|
assert_eq!(
|
|
Pornhd3xProvider::build_source_cookie_name("49Q27JL3HCPVNJQN"),
|
|
"826avrbi6m49vd7shxkn985m49Q27JL3HCPVNJQNk06twz87wwxtp3dqiicks2df"
|
|
);
|
|
assert_eq!(
|
|
Pornhd3xProvider::build_source_hash("49Q27JL3HCPVNJQN", "abcdef"),
|
|
"8846c87b6e67760c42094713ec6f278a"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_known_dates() {
|
|
assert!(Pornhd3xProvider::parse_uploaded_at(
|
|
"Brazzers / - Ryan Reid, Kayley Gunner, Mick Blue Surprise Dick For Their Anniversary / 22.3.2026"
|
|
)
|
|
.is_some());
|
|
assert!(
|
|
Pornhd3xProvider::parse_uploaded_at(
|
|
"New Dana Vespoli Kimmy Kimm A Brand Nude Incentive (22-03-2026)"
|
|
)
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builds_proxied_video_urls() {
|
|
let provider = Pornhd3xProvider::new_for_tests();
|
|
let options = ServerOptions {
|
|
featured: None,
|
|
category: None,
|
|
sites: None,
|
|
filter: None,
|
|
language: None,
|
|
public_url_base: Some("https://example.com".to_string()),
|
|
requester: None,
|
|
network: None,
|
|
stars: None,
|
|
categories: None,
|
|
duration: None,
|
|
sort: None,
|
|
sexuality: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
provider.proxied_video(
|
|
&options,
|
|
"https://www.pornhd3x.tv/movies/example-video",
|
|
None,
|
|
),
|
|
"https://example.com/proxy/pornhd3x/www.pornhd3x.tv/movies/example-video"
|
|
);
|
|
assert_eq!(
|
|
provider.proxied_video(
|
|
&options,
|
|
"https://www.pornhd3x.tv/movies/example-video",
|
|
Some("720p"),
|
|
),
|
|
"https://example.com/proxy/pornhd3x/www.pornhd3x.tv/movies/example-video/__quality__/720p"
|
|
);
|
|
}
|
|
}
|