Files
hottub/src/providers/hooporn.rs

607 lines
22 KiB
Rust

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::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::stream::{self, StreamExt};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use scraper::{Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "studio-network",
tags: &["studio", "hd", "pornstars", "free"],
};
const BASE_URL: &str = "https://hooporn.com";
const CHANNEL_ID: &str = "hooporn";
const ENRICH_CONCURRENCY: usize = 8;
// Scraped from /categories (`div.channels-card a[href^="/category/"]`).
const CATEGORIES: &[(&str, &str)] = &[
("anal", "anal"),
("ass-licking", "ass licking"),
("atm", "ass to mouth"),
("babe", "babe"),
("bbw", "BBW"),
("big-ass", "big ass"),
("big-tits", "big tits"),
("black", "black"),
("blonde", "blonde"),
("blowjob", "blowjob"),
("bondage", "bondage"),
("brunette", "brunette"),
("busty", "busty"),
("casting", "casting"),
("college", "college"),
("compilation", "compilation"),
("creampie", "creampie"),
("cuckold", "cuckold"),
("czech", "czech"),
("deepthroat", "deepthroat"),
("doggystyle", "doggystyle"),
("dp", "double penetration"),
("ebony", "ebony"),
("fantasy", "fantasy"),
("fetish", "fetish"),
("fingering", "fingering"),
("footjob", "footjob"),
("foursome", "foursome"),
("gangbang", "gangbang"),
("gangbang-creampie", "gangbang creampie"),
("gaping", "gaping"),
("german", "german"),
("gloryhole", "gloryhole"),
("hairy", "hairy"),
("handjob", "handjob"),
("hardcore", "hardcore"),
("homemade", "homemade"),
("interracial", "interracial"),
("latina", "latina"),
("lesbian", "lesbian"),
("lingerie", "lingerie"),
("massage", "massage"),
("masturbation", "masturbation"),
("mature", "mature"),
("milf", "MILF"),
("orgy", "orgy"),
("outdoor", "outdoor"),
("petite", "petite"),
("pov", "POV"),
("public", "public"),
("pussy-licking", "pussy licking"),
("red-head", "red head"),
("riding", "riding"),
("russian", "russian"),
("skinny", "skinny"),
("small-tits", "small tits"),
("solo", "solo"),
("squirt", "squirt"),
("swallow", "swallow"),
("teen", "teen"),
("threesome", "threesome"),
("toys", "toys"),
("wife", "wife"),
];
error_chain! {
foreign_links {
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct HoopornProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Latest,
Category(String),
Pornstar(String),
Studio(String),
Search(String),
/// Free-form query: a pornstar page or a studio page if one exists by that
/// name (probed on the site), else a keyword search.
Keyword(String),
}
/// Detail-page data: the player iframe plus taxonomy links.
#[derive(Debug, Default, PartialEq, Eq)]
struct Detail {
iframe: Option<String>,
tags: Vec<String>,
studio: Option<(String, String)>,
}
impl HoopornProvider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let mut category_options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
category_options.extend(CATEGORIES.iter().map(|(slug, title)| FilterOption {
id: (*slug).to_string(),
title: Self::title_case(title),
}));
Channel {
id: CHANNEL_ID.to_string(),
name: "HooPorn".to_string(),
description: "Free HD studio videos from HooPorn with category, pornstar and studio archives.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hooporn.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse HooPorn by newest videos.".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: vec![FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
}],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Browse a HooPorn category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: None,
}
}
fn title_case(value: &str) -> String {
if value.chars().any(|c| c.is_uppercase()) {
return value.to_string();
}
value
.split(' ')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn slugify(value: &str) -> String {
let mut out = String::new();
let mut last_dash = true;
for c in value.trim().chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn category_slug(value: &str) -> Option<&'static str> {
let slug = Self::slugify(value);
if slug.is_empty() {
return None;
}
CATEGORIES
.iter()
.find_map(|(s, title)| (*s == slug || Self::slugify(title) == slug).then_some(*s))
}
fn pick_target(query: Option<&str>, options: &ServerOptions) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
for prefix in ["cat:", "category:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let slug = Self::category_slug(rest)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(rest));
return Target::Category(slug);
}
}
for prefix in ["pornstar:", "star:", "model:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Pornstar(Self::slugify(rest));
}
}
for prefix in ["studio:", "site:"] {
if let Some(rest) = lower.strip_prefix(prefix) {
return Target::Studio(Self::slugify(rest).replace('-', ""));
}
}
if let Some(slug) = Self::category_slug(q) {
return Target::Category(slug.to_string());
}
return Target::Keyword(q.to_string());
}
if let Some(cat) = options.categories.as_deref() {
let cat = cat.trim();
if !cat.is_empty() && !cat.eq_ignore_ascii_case("all") {
let slug = Self::category_slug(cat)
.map(str::to_string)
.unwrap_or_else(|| Self::slugify(cat));
return Target::Category(slug);
}
}
Target::Latest
}
fn build_listing_url(target: &Target, page: u32) -> String {
match target {
Target::Latest => format!("{BASE_URL}/home/{page}"),
Target::Category(slug) => format!("{BASE_URL}/category/{slug}/{page}"),
Target::Pornstar(slug) => format!("{BASE_URL}/pornstar/{slug}/{page}"),
Target::Studio(slug) => format!("{BASE_URL}/studio/{slug}/{page}"),
Target::Search(query) | Target::Keyword(query) => format!(
"{BASE_URL}/search?q={}&p={page}",
utf8_percent_encode(query, NON_ALPHANUMERIC)
),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value).map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
}
fn absolute(url: &str) -> String {
if let Some(rest) = url.strip_prefix("//") {
format!("https://{rest}")
} else if url.starts_with('/') {
format!("{BASE_URL}{url}")
} else {
url.to_string()
}
}
fn parse_listing(html: &str) -> Result<Vec<VideoItem>> {
let document = Html::parse_document(html);
let card_sel = Self::selector("div.video-card")?;
let link_sel = Self::selector("div.video-title a[href^=\"/watch/\"]")?;
let img_sel = Self::selector("img")?;
let time_sel = Self::selector(".time")?;
let created_sel = Self::selector("span.create")?;
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
for card in document.select(&card_sel) {
let Some(link) = card.select(&link_sel).next() else {
continue;
};
let href = link.value().attr("href").unwrap_or_default();
let id = href.trim_start_matches("/watch/").trim_matches('/').to_string();
if id.is_empty() || !seen.insert(id.clone()) {
continue;
}
let title = link.text().collect::<String>().trim().to_string();
if title.is_empty() {
continue;
}
let thumb = card
.select(&img_sel)
.next()
.and_then(|i| i.value().attr("data-src"))
.map(Self::absolute)
.unwrap_or_default();
let duration = card
.select(&time_sel)
.next()
.map(|t| t.text().collect::<String>())
.and_then(|t| parse_time_to_seconds(t.trim()))
.and_then(|s| u32::try_from(s).ok())
.unwrap_or(0);
let mut item = VideoItem::new(
id.clone(),
title,
format!("{BASE_URL}/watch/{id}"),
CHANNEL_ID.to_string(),
thumb,
duration,
);
item.uploadedAt = card
.select(&created_sel)
.next()
.and_then(|c| c.text().collect::<String>().trim().parse::<u64>().ok());
item.aspectRatio = Some(16.0 / 9.0);
items.push(item);
}
Ok(items)
}
fn parse_detail(html: &str) -> Result<Detail> {
let document = Html::parse_document(html);
let iframe_sel = Self::selector("iframe[src*=\"/video/\"]")?;
let tag_sel = Self::selector("p.tag-link a[href^=\"/category/\"]")?;
let star_sel = Self::selector("a[href^=\"/pornstar/\"]")?;
let studio_sel = Self::selector("a[href^=\"/studio/\"]")?;
let mut detail = Detail::default();
detail.iframe = document
.select(&iframe_sel)
.next()
.and_then(|f| f.value().attr("src"))
.map(Self::absolute);
let mut push = |tag: String| {
let tag = tag.trim().trim_start_matches('#').trim().to_string();
if !tag.is_empty() && !detail.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
detail.tags.push(tag);
}
};
for a in document.select(&tag_sel) {
push(a.text().collect::<String>());
}
for a in document.select(&star_sel) {
push(a.text().collect::<String>());
}
if let Some(a) = document.select(&studio_sel).next() {
let name = a.text().collect::<String>().trim().to_string();
let slug = a
.value()
.attr("href")
.unwrap_or_default()
.trim_start_matches("/studio/")
.trim_matches('/')
.to_string();
if !name.is_empty() && !slug.is_empty() {
detail.studio = Some((name, slug));
}
}
Ok(detail)
}
/// The player frame carries `var video = "https://videos…mp4?md5=…&expires=…"`.
fn parse_player_video(html: &str) -> Option<String> {
let marker = "var video = \"";
let start = html.find(marker)? + marker.len();
let end = html[start..].find('"')? + start;
let url = html[start..end].replace("&amp;", "&");
url.starts_with("http").then_some(url)
}
async fn enrich(items: Vec<VideoItem>, requester: Requester) -> Vec<VideoItem> {
let referer = format!("{BASE_URL}/");
stream::iter(items.into_iter().map(|mut item| {
let mut requester = requester.clone();
let referer = referer.clone();
async move {
let headers = vec![("Referer".to_string(), referer.clone())];
let page = requester
.get_with_headers(&item.url, headers.clone(), None)
.await
.ok()?;
let detail = Self::parse_detail(&page).ok()?;
let frame = detail.iframe?;
let player = requester
.get_with_headers(&frame, headers, None)
.await
.ok()?;
let video_url = Self::parse_player_video(&player)?;
item.formats = Some(vec![
VideoFormat::new(video_url, "auto".to_string(), "mp4".to_string())
.format_id("mp4".to_string()),
]);
if !detail.tags.is_empty() {
item.tags = Some(detail.tags);
}
if let Some((name, slug)) = detail.studio {
item.uploaderUrl = Some(format!("{BASE_URL}/studio/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
item.uploader = Some(name);
}
Some(item)
}
}))
.buffered(ENRICH_CONCURRENCY)
.filter_map(|item| async move { item })
.collect::<Vec<_>>()
.await
}
/// Fetches a listing page; `None` for a real 404 (see `Requester::get_probe`).
async fn fetch_html(requester: &mut Requester, url: &str) -> Result<Option<String>> {
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
requester
.get_probe(url, headers)
.await
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))
}
async fn fetch(target: &Target, page: u32, options: &ServerOptions) -> Result<Vec<VideoItem>> {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch");
let html = match target {
Target::Keyword(query) => {
// Probe page 1 of the pornstar and studio archive for this
// name; the first that exists wins, otherwise keyword search.
let candidates = [
Target::Pornstar(Self::slugify(query)),
Target::Studio(Self::slugify(query).replace('-', "")),
];
let mut found = None;
for candidate in candidates {
let slug_ok = match &candidate {
Target::Pornstar(s) | Target::Studio(s) => !s.is_empty(),
_ => false,
};
if !slug_ok {
continue;
}
let first = Self::fetch_html(&mut requester, &Self::build_listing_url(&candidate, 1)).await?;
if let Some(body) = first.filter(|b| b.contains("class=\"video-card\"")) {
found = Some(if page <= 1 {
Some(body)
} else {
Self::fetch_html(&mut requester, &Self::build_listing_url(&candidate, page)).await?
});
break;
}
}
match found {
Some(html) => html,
None => Self::fetch_html(&mut requester, &Self::build_listing_url(target, page)).await?,
}
}
_ => Self::fetch_html(&mut requester, &Self::build_listing_url(target, page)).await?,
};
let Some(html) = html else {
return Ok(vec![]);
};
let items = Self::parse_listing(&html)?;
Ok(Self::enrich(items, requester).await)
}
}
#[async_trait]
impl Provider for HoopornProvider {
async fn get_videos(
&self,
_cache: VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
_per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let target = Self::pick_target(query.as_deref(), &options);
match Self::fetch(&target, page, &options).await {
Ok(items) => items,
Err(e) => {
report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> ServerOptions {
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,
client_version: None,
}
}
#[test]
fn builds_urls() {
assert_eq!(HoopornProvider::build_listing_url(&Target::Latest, 2), "https://hooporn.com/home/2");
assert_eq!(
HoopornProvider::build_listing_url(&Target::Category("milf".into()), 3),
"https://hooporn.com/category/milf/3"
);
assert_eq!(
HoopornProvider::build_listing_url(&Target::Search("big tits".into()), 2),
"https://hooporn.com/search?q=big%20tits&p=2"
);
assert_eq!(
HoopornProvider::build_listing_url(&Target::Studio("mylf".into()), 1),
"https://hooporn.com/studio/mylf/1"
);
}
#[test]
fn routes_queries() {
let o = opts();
assert_eq!(
HoopornProvider::pick_target(Some("Double Penetration"), &o),
Target::Category("dp".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("pornstar:Gal Ritchie"), &o),
Target::Pornstar("gal-ritchie".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("studio:Family Strokes"), &o),
Target::Studio("familystrokes".into())
);
assert_eq!(
HoopornProvider::pick_target(Some("golf"), &o),
Target::Keyword("golf".into())
);
assert_eq!(HoopornProvider::pick_target(None, &o), Target::Latest);
}
#[test]
fn parses_cards_detail_and_player() {
let list = r#"<div class="video-card"><div class="video-card-image">
<a class="popout" href="/watch/abc123"><img src="//cdn.hooporn.com/img/blank.gif" data-src="//imgs.hooporn.com/t.jpg?v2" alt="x"></a>
<div class="time">37:16</div></div><div class="video-card-body"><div class="video-title">
<a class="popout" href="/watch/abc123">A &amp; B - Title</a></div>
<div class="video-view"><span class="create">1792152610</span></div></div></div>"#;
let items = HoopornProvider::parse_listing(list).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "A & B - Title");
assert_eq!(items[0].thumb, "https://imgs.hooporn.com/t.jpg?v2");
assert_eq!(items[0].duration, 37 * 60 + 16);
assert_eq!(items[0].uploadedAt, Some(1792152610));
let detail = r##"<iframe src="//vid.hooporn.com/video/182550"></iframe>
<p class="tag-link"><span><a class="popout" href="/category/milf">#milf</a></span></p>
<a href="/pornstar/gal-ritchie">gal ritchie</a><a href="/studio/familystrokes">FAMILY STROKES</a>"##;
let d = HoopornProvider::parse_detail(detail).unwrap();
assert_eq!(d.iframe.as_deref(), Some("https://vid.hooporn.com/video/182550"));
assert_eq!(d.tags, vec!["milf".to_string(), "gal ritchie".to_string()]);
assert_eq!(d.studio, Some(("FAMILY STROKES".to_string(), "familystrokes".to_string())));
let player = r#"var video = "https://videos.trendyporn.com/v/a.mp4?md5=X&amp;expires=1";"#;
assert_eq!(
HoopornProvider::parse_player_video(player).as_deref(),
Some("https://videos.trendyporn.com/v/a.mp4?md5=X&expires=1")
);
}
}