613 lines
21 KiB
Rust
613 lines
21 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::parse_abbreviated_number;
|
|
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 htmlentity::entity::{ICodedDataTrait, decode};
|
|
use scraper::{Html, Selector};
|
|
use std::collections::HashSet;
|
|
use std::vec;
|
|
use url::form_urlencoded::Serializer;
|
|
|
|
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
|
crate::providers::ProviderChannelMetadata {
|
|
group_id: "fetish-kink",
|
|
tags: &["freeuse", "hypno", "mind-control"],
|
|
};
|
|
|
|
error_chain! {
|
|
foreign_links {
|
|
Io(std::io::Error);
|
|
HttpRequest(wreq::Error);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FreeusepornProvider {
|
|
url: String,
|
|
}
|
|
|
|
impl FreeusepornProvider {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
url: "https://www.freeuseporn.com".to_string(),
|
|
}
|
|
}
|
|
|
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
|
Channel {
|
|
id: "freeuseporn".to_string(),
|
|
name: "FreeusePorn".to_string(),
|
|
description: "FreeusePorn streams freeuse, hypno, mind control, ignored sex, and related fetish videos.".to_string(),
|
|
premium: false,
|
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=freeuseporn.com".to_string(),
|
|
status: "active".to_string(),
|
|
categories: vec![],
|
|
options: vec![
|
|
ChannelOption {
|
|
id: "sort".to_string(),
|
|
title: "Sort".to_string(),
|
|
description: "Sort the videos".to_string(),
|
|
systemImage: "list.number".to_string(),
|
|
colorName: "blue".to_string(),
|
|
options: vec![
|
|
FilterOption {
|
|
id: "recent".to_string(),
|
|
title: "Most Recent".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "viewed".to_string(),
|
|
title: "Most Viewed".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "rated".to_string(),
|
|
title: "Top Rated".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "favorites".to_string(),
|
|
title: "Top Favorites".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "watched".to_string(),
|
|
title: "Being Watched".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "category".to_string(),
|
|
title: "Category".to_string(),
|
|
description: "Filter by category".to_string(),
|
|
systemImage: "square.grid.2x2".to_string(),
|
|
colorName: "orange".to_string(),
|
|
options: vec![
|
|
FilterOption {
|
|
id: "all".to_string(),
|
|
title: "All".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "mind-control".to_string(),
|
|
title: "Mind Control".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "general-freeuse".to_string(),
|
|
title: "General Freeuse".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "free-service".to_string(),
|
|
title: "Free Service".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "forced".to_string(),
|
|
title: "Forced".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "japanese".to_string(),
|
|
title: "Japanese".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "time-stop".to_string(),
|
|
title: "Time Stop".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "ignored-sex".to_string(),
|
|
title: "Ignored Sex".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "glory-hole".to_string(),
|
|
title: "Glory Hole".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
nsfw: true,
|
|
cacheDuration: Some(1800),
|
|
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
|
|
}
|
|
}
|
|
|
|
fn absolute_url(&self, url: &str) -> String {
|
|
if url.starts_with("http://") || url.starts_with("https://") {
|
|
url.to_string()
|
|
} else if url.starts_with('/') {
|
|
format!("{}{}", self.url, url)
|
|
} else {
|
|
format!("{}/{}", self.url, url.trim_start_matches('/'))
|
|
}
|
|
}
|
|
|
|
fn sort_param(sort: &str) -> &'static str {
|
|
match sort {
|
|
"viewed" => "mv",
|
|
"rated" => "tr",
|
|
"favorites" => "tf",
|
|
"watched" => "bw",
|
|
_ => "mr",
|
|
}
|
|
}
|
|
|
|
fn append_sort_and_page(&self, base_url: &str, sort: &str, page: u8) -> String {
|
|
let mut params = vec![format!("o={}", Self::sort_param(sort))];
|
|
if page > 1 {
|
|
params.push(format!("page={page}"));
|
|
}
|
|
|
|
if params.is_empty() {
|
|
return base_url.to_string();
|
|
}
|
|
|
|
let separator = if base_url.contains('?') { "&" } else { "?" };
|
|
format!("{base_url}{separator}{}", params.join("&"))
|
|
}
|
|
|
|
fn build_list_url(&self, sort: &str, page: u8, category: Option<&str>) -> String {
|
|
let path = if let Some(category) = category
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty() && *value != "all")
|
|
{
|
|
format!("/videos/{}", category)
|
|
} else {
|
|
"/videos".to_string()
|
|
};
|
|
|
|
let base_url = format!("{}{}", self.url, path);
|
|
self.append_sort_and_page(&base_url, sort, page)
|
|
}
|
|
|
|
fn build_search_request_body(query: &str) -> String {
|
|
let mut serializer = Serializer::new(String::new());
|
|
serializer.append_pair("search_query", query);
|
|
serializer.finish()
|
|
}
|
|
|
|
async fn resolve_search_url(&self, query: &str, options: &ServerOptions) -> Result<String> {
|
|
let search_url = format!("{}/search/videos", self.url);
|
|
let search_body = Self::build_search_request_body(query);
|
|
let referer = format!("{}/videos", self.url);
|
|
let mut requester = requester_or_default(options, module_path!(), "missing_requester");
|
|
let response = requester
|
|
.post(
|
|
&search_url,
|
|
&search_body,
|
|
vec![
|
|
("Content-Type", "application/x-www-form-urlencoded"),
|
|
("Referer", referer.as_str()),
|
|
],
|
|
)
|
|
.await
|
|
.map_err(|error| format!("search submit failed url={search_url}; error={error}"))?;
|
|
|
|
Ok(response.uri().to_string().trim_end_matches('/').to_string())
|
|
}
|
|
|
|
fn build_formats(&self, id: &str) -> Vec<VideoFormat> {
|
|
let hd = VideoFormat::new(
|
|
format!("{}/media/videos/h264/{}_720p.mp4", self.url, id),
|
|
"720p".to_string(),
|
|
"video/mp4".to_string(),
|
|
)
|
|
.format_id("720p".to_string())
|
|
.format_note("720p".to_string());
|
|
let sd = VideoFormat::new(
|
|
format!("{}/media/videos/h264/{}_480p.mp4", self.url, id),
|
|
"480p".to_string(),
|
|
"video/mp4".to_string(),
|
|
)
|
|
.format_id("480p".to_string())
|
|
.format_note("480p".to_string());
|
|
vec![hd, sd]
|
|
}
|
|
|
|
fn normalized_text(text: &str) -> String {
|
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
fn decode_text(value: &str) -> String {
|
|
decode(value.as_bytes())
|
|
.to_string()
|
|
.unwrap_or_else(|_| value.to_string())
|
|
}
|
|
|
|
fn parse_views(value: &str) -> Option<u32> {
|
|
let digits = value
|
|
.chars()
|
|
.filter(|character| character.is_ascii_digit() || *character == '.' || *character == 'K' || *character == 'M' || *character == 'B' || *character == 'k' || *character == 'm' || *character == 'b')
|
|
.collect::<String>();
|
|
if digits.is_empty() {
|
|
return None;
|
|
}
|
|
parse_abbreviated_number(&digits).map(|views| views as u32)
|
|
}
|
|
|
|
fn parse_rating(value: &str) -> Option<f32> {
|
|
value
|
|
.trim()
|
|
.trim_end_matches('%')
|
|
.parse::<f32>()
|
|
.ok()
|
|
}
|
|
|
|
fn parse_video_item_from_anchor(
|
|
&self,
|
|
anchor: scraper::ElementRef<'_>,
|
|
selectors: &FreeusepornSelectors,
|
|
) -> Option<VideoItem> {
|
|
let href = anchor.value().attr("href")?;
|
|
if !href.contains("/video/") {
|
|
return None;
|
|
}
|
|
|
|
let absolute_url = self.absolute_url(href);
|
|
let id = absolute_url.split('/').nth(4)?.to_string();
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let title_raw = anchor
|
|
.select(&selectors.title)
|
|
.next()
|
|
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
|
.filter(|value| !value.is_empty())
|
|
.or_else(|| anchor.value().attr("title").map(Self::normalized_text))
|
|
.or_else(|| {
|
|
anchor
|
|
.select(&selectors.image)
|
|
.next()
|
|
.and_then(|element| element.value().attr("alt"))
|
|
.map(Self::normalized_text)
|
|
})?;
|
|
let title = Self::decode_text(&title_raw);
|
|
|
|
let thumb = anchor
|
|
.select(&selectors.image)
|
|
.next()
|
|
.and_then(|element| element.value().attr("src"))
|
|
.map(|src| self.absolute_url(src))
|
|
.unwrap_or_default();
|
|
|
|
let duration = anchor
|
|
.select(&selectors.duration)
|
|
.next()
|
|
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
|
.and_then(|value| parse_time_to_seconds(&value))
|
|
.unwrap_or(0) as u32;
|
|
|
|
let mut stats = anchor
|
|
.select(&selectors.video_stat)
|
|
.map(|element| Self::normalized_text(&element.text().collect::<Vec<_>>().join(" ")))
|
|
.collect::<Vec<_>>();
|
|
stats.retain(|value| !value.is_empty());
|
|
let views = stats.first().and_then(|value| Self::parse_views(value));
|
|
let rating = stats.get(1).and_then(|value| Self::parse_rating(value));
|
|
|
|
let mut item = VideoItem::new(
|
|
id.clone(),
|
|
title,
|
|
absolute_url,
|
|
"freeuseporn".to_string(),
|
|
thumb,
|
|
duration,
|
|
)
|
|
.views(views.unwrap_or(0));
|
|
|
|
if views.is_none() {
|
|
item.views = None;
|
|
}
|
|
item.rating = rating;
|
|
item.formats = Some(self.build_formats(&id));
|
|
|
|
Some(item)
|
|
}
|
|
|
|
fn get_video_items_from_html(&self, html: &str) -> Vec<VideoItem> {
|
|
if html.trim().is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let document = Html::parse_document(html);
|
|
let selectors = FreeusepornSelectors::new();
|
|
let primary_anchors = document
|
|
.select(&selectors.list_anchor)
|
|
.collect::<Vec<_>>();
|
|
let anchors = if primary_anchors.is_empty() {
|
|
document
|
|
.select(&selectors.fallback_anchor)
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
primary_anchors
|
|
};
|
|
|
|
let mut seen = HashSet::new();
|
|
let mut items = Vec::new();
|
|
|
|
for anchor in anchors {
|
|
let Some(item) = self.parse_video_item_from_anchor(anchor, &selectors) else {
|
|
continue;
|
|
};
|
|
if seen.insert(item.id.clone()) {
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
async fn fetch_listing(
|
|
&self,
|
|
cache: VideoCache,
|
|
url: String,
|
|
options: ServerOptions,
|
|
error_context: &str,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let old_items = match cache.get(&url) {
|
|
Some((time, items)) => {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
|
|
return Ok(items.clone());
|
|
}
|
|
items.clone()
|
|
}
|
|
None => vec![],
|
|
};
|
|
|
|
let mut requester = requester_or_default(&options, module_path!(), "missing_requester");
|
|
let text = match requester.get(&url, None).await {
|
|
Ok(text) => text,
|
|
Err(error) => {
|
|
report_provider_error(
|
|
"freeuseporn",
|
|
error_context,
|
|
&format!("url={url}; error={error}"),
|
|
)
|
|
.await;
|
|
return Ok(old_items);
|
|
}
|
|
};
|
|
|
|
let items = self.get_video_items_from_html(&text);
|
|
if items.is_empty() {
|
|
return Ok(old_items);
|
|
}
|
|
|
|
cache.remove(&url);
|
|
cache.insert(url, items.clone());
|
|
Ok(items)
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
cache: VideoCache,
|
|
page: u8,
|
|
sort: &str,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let url = self.build_list_url(sort, page, options.category.as_deref());
|
|
self.fetch_listing(cache, url, options, "get.request").await
|
|
}
|
|
|
|
async fn query(
|
|
&self,
|
|
cache: VideoCache,
|
|
page: u8,
|
|
query: &str,
|
|
sort: &str,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let search_base = match self.resolve_search_url(query, &options).await {
|
|
Ok(url) => url,
|
|
Err(error) => {
|
|
report_provider_error(
|
|
"freeuseporn",
|
|
"query.search_submit",
|
|
&error.to_string(),
|
|
)
|
|
.await;
|
|
return Ok(vec![]);
|
|
}
|
|
};
|
|
let url = self.append_sort_and_page(&search_base, sort, page);
|
|
self.fetch_listing(cache, url, options, "query.request").await
|
|
}
|
|
}
|
|
|
|
struct FreeusepornSelectors {
|
|
list_anchor: Selector,
|
|
fallback_anchor: Selector,
|
|
title: Selector,
|
|
image: Selector,
|
|
duration: Selector,
|
|
video_stat: Selector,
|
|
}
|
|
|
|
impl FreeusepornSelectors {
|
|
fn new() -> Self {
|
|
Self {
|
|
list_anchor: Selector::parse("#videos-list a[href]").expect("valid freeuseporn list selector"),
|
|
fallback_anchor: Selector::parse("a[href]").expect("valid freeuseporn fallback selector"),
|
|
title: Selector::parse(".v-name").expect("valid freeuseporn title selector"),
|
|
image: Selector::parse("img").expect("valid freeuseporn image selector"),
|
|
duration: Selector::parse(".duration").expect("valid freeuseporn duration selector"),
|
|
video_stat: Selector::parse(".video-stats li").expect("valid freeuseporn stats selector"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for FreeusepornProvider {
|
|
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::<u8>().unwrap_or(1);
|
|
let videos = match query {
|
|
Some(query) => self.query(cache, page, &query, &sort, options).await,
|
|
None => self.get(cache, page, &sort, options).await,
|
|
};
|
|
|
|
match videos {
|
|
Ok(items) => items,
|
|
Err(error) => {
|
|
eprintln!("freeuseporn provider error: {error}");
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
|
Some(self.build_channel(clientversion))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn provider() -> FreeusepornProvider {
|
|
FreeusepornProvider::new()
|
|
}
|
|
|
|
#[test]
|
|
fn builds_listing_urls_for_sort_category_and_search() {
|
|
let provider = provider();
|
|
|
|
assert_eq!(
|
|
provider.build_list_url("recent", 1, None),
|
|
"https://www.freeuseporn.com/videos?o=mr"
|
|
);
|
|
assert_eq!(
|
|
provider.build_list_url("viewed", 2, Some("mind-control")),
|
|
"https://www.freeuseporn.com/videos/mind-control?o=mv&page=2"
|
|
);
|
|
assert_eq!(
|
|
provider.append_sort_and_page(
|
|
"https://www.freeuseporn.com/search/videos/Nicole-Kitt",
|
|
"favorites",
|
|
3
|
|
),
|
|
"https://www.freeuseporn.com/search/videos/Nicole-Kitt?o=tf&page=3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builds_search_request_body_with_form_encoding() {
|
|
assert_eq!(
|
|
FreeusepornProvider::build_search_request_body("Nicole Kitt & Cory Chase"),
|
|
"search_query=Nicole+Kitt+%26+Cory+Chase"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_listing_items_and_builds_formats() {
|
|
let provider = provider();
|
|
let html = r#"
|
|
<ul class="grid" id="videos-list">
|
|
<li>
|
|
<div class="item">
|
|
<div class="thumbnail">
|
|
<div class="embed">
|
|
<iframe src="https://ads.example"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<li>
|
|
<a href="/video/9579/nicole-kitt-shady-slut-keeps-confessing" class="thumb-wrap-link">
|
|
<div class="item">
|
|
<div class="thumbnail overlay" id="playvthumb_9579">
|
|
<div class="sub-data">
|
|
<span class="duration">59:09</span>
|
|
</div>
|
|
<img src="https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg" alt="Nicole Kitt & The Truth"/>
|
|
</div>
|
|
<div class="info">
|
|
<span class="v-name">Nicole Kitt & The Truth</span>
|
|
<ul class="video-stats">
|
|
<li><i class="far fa-eye"></i>52180</li>
|
|
<li><i class="far fa-heart"></i>100%</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a href="https://www.freeuseporn.com/video/9578/lollipop-time-stop-2">
|
|
<div class="item">
|
|
<div class="thumbnail overlay">
|
|
<div class="sub-data">
|
|
<span class="duration">16:27</span>
|
|
</div>
|
|
<img src="https://www.freeuseporn.com/media/videos/tmb/9578/1.jpg" alt="Lollipop time stop 2"/>
|
|
</div>
|
|
<div class="info">
|
|
<span class="v-name">Lollipop time stop 2</span>
|
|
<ul class="video-stats">
|
|
<li><i class="far fa-eye"></i>35058</li>
|
|
<li><i class="far fa-heart"></i>88%</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
"#;
|
|
|
|
let items = provider.get_video_items_from_html(html);
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].id, "9579");
|
|
assert_eq!(items[0].title, "Nicole Kitt & The Truth");
|
|
assert_eq!(
|
|
items[0].url,
|
|
"https://www.freeuseporn.com/video/9579/nicole-kitt-shady-slut-keeps-confessing"
|
|
);
|
|
assert_eq!(
|
|
items[0].thumb,
|
|
"https://www.freeuseporn.com/media/videos/tmb/9579/1.jpg"
|
|
);
|
|
assert_eq!(items[0].duration, 3549);
|
|
assert_eq!(items[0].views, Some(52180));
|
|
assert_eq!(items[0].rating, Some(100.0));
|
|
assert_eq!(items[0].formats.as_ref().map(|formats| formats.len()), Some(2));
|
|
assert_eq!(
|
|
items[0]
|
|
.formats
|
|
.as_ref()
|
|
.and_then(|formats| formats.first())
|
|
.map(|format| format.url.as_str()),
|
|
Some("https://www.freeuseporn.com/media/videos/h264/9579_720p.mp4")
|
|
);
|
|
assert_eq!(items[1].id, "9578");
|
|
assert_eq!(items[1].rating, Some(88.0));
|
|
}
|
|
}
|