Files
hottub/src/providers/porn4fans.rs
2026-07-06 16:07:54 +00:00

954 lines
34 KiB
Rust

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, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use regex::Regex;
use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::thread;
const CHANNEL_ID: &str = "porn4fans";
const BASE_URL: &str = "https://www.porn4fans.com";
/// Bounded concurrency for detail-page enrichment (one fetch per card).
const ENRICH_CONCURRENCY: usize = 8;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "onlyfans",
tags: &["creator", "premium", "clips"],
};
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
}
#[derive(Debug, Clone)]
pub struct Porn4fansProvider {
url: String,
/// Display list exposed via the `categories` status option.
categories: Arc<RwLock<Vec<FilterOption>>>,
/// normalized category title -> site slug, for shortcut routing.
category_map: Arc<RwLock<HashMap<String, String>>>,
}
/// Where a `/api/videos` request should be routed on the site. Every variant
/// resolves to the same KVS `get_block` async fragment, just with a different
/// path + `block_id` + pagination parameter.
#[derive(Debug, Clone)]
enum Target {
/// Default `/onlyfans-videos/` latest feed.
Latest,
/// Native keyword search.
Search { query: String },
/// `/categories/{slug}/` archive.
Category { slug: String },
/// `/tags/{slug}/` archive.
Tag { slug: String },
/// `/models/{slug}/` archive (uploader shortcut).
Model { slug: String },
}
#[derive(Debug, Clone)]
struct Porn4fansCard {
id: String,
title: String,
page_url: String,
thumb: String,
duration: u32,
views: Option<u32>,
preview: Option<String>,
uploader: Option<String>,
uploader_url: Option<String>,
uploader_slug: Option<String>,
}
impl Porn4fansProvider {
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(),
}])),
category_map: Arc::new(RwLock::new(HashMap::new())),
};
provider.spawn_initial_load();
provider
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let categories = self
.categories
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
Channel {
id: CHANNEL_ID.to_string(),
name: "Porn4Fans".to_string(),
description: "OnlyFans creator clips — latest feed, keyword search, plus category, tag and model shortcuts."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.porn4fans.com"
.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: "Feed ordering.".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: "longest".to_string(),
title: "Longest".to_string(),
},
],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Jump directly to a category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: categories,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
fn sort_by(sort: &str) -> &'static str {
match sort {
"popular" | "viewed" | "most-viewed" => "video_viewed",
"rated" | "rating" | "top-rated" => "rating",
"longest" | "duration" => "duration",
_ => "post_date",
}
}
/// lowercase, spaces/underscores -> single dash, drop anything else.
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in value.trim().to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
prev_dash = false;
} else if matches!(ch, ' ' | '-' | '_' | '+') {
if !prev_dash && !slug.is_empty() {
slug.push('-');
prev_dash = true;
}
}
}
slug.trim_matches('-').to_string()
}
/// Normalize a display title for lookup map keys.
fn normalize_title(value: &str) -> String {
value
.trim()
.trim_start_matches('#')
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn resolve_category_slug(&self, value: &str) -> Option<String> {
let normalized = Self::normalize_title(value);
if normalized.is_empty() || normalized == "all" {
return None;
}
if let Some(slug) = self
.category_map
.read()
.ok()
.and_then(|map| map.get(&normalized).cloned())
{
return Some(slug);
}
None
}
/// Decide where a request is routed: explicit option/prefix first, then a
/// bare query that matches a known category, otherwise keyword search.
fn target_from_request(
&self,
query: Option<&str>,
category: Option<&str>,
) -> Target {
if let Some(category) = category {
if let Some(slug) = self.resolve_category_slug(category) {
return Target::Category { slug };
}
}
if let Some(raw) = query {
let trimmed = raw.trim();
if !trimmed.is_empty() {
for prefix in ["category:", "cat:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let slug = self
.resolve_category_slug(rest)
.unwrap_or_else(|| Self::slugify(rest));
if !slug.is_empty() {
return Target::Category { slug };
}
}
}
if let Some(rest) = trimmed.strip_prefix("tag:") {
let slug = Self::slugify(rest);
if !slug.is_empty() {
return Target::Tag { slug };
}
}
for prefix in ["model:", "uploader:", "pornstar:", "star:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let slug = Self::slugify(rest);
if !slug.is_empty() {
return Target::Model { slug };
}
}
}
// A bare query that is exactly a known category goes to that
// archive (better curated than keyword search).
if let Some(slug) = self.resolve_category_slug(trimmed) {
return Target::Category { slug };
}
return Target::Search {
query: trimmed.to_string(),
};
}
}
Target::Latest
}
/// Build the async `get_block` URL plus the Referer to send with it.
fn build_block_url(&self, target: &Target, sort: &str, page: u32) -> (String, String) {
let sort_by = Self::sort_by(sort);
match target {
Target::Latest => {
let referer = format!("{}/onlyfans-videos/", self.url);
let url = format!(
"{referer}?mode=async&function=get_block&block_id=custom_list_videos_latest_videos_list&sort_by={sort_by}&from={page}"
);
(url, referer)
}
Target::Category { slug } => {
let referer = format!("{}/categories/{slug}/", self.url);
let url = format!(
"{referer}?mode=async&function=get_block&block_id=custom_list_videos_common_videos_list&sort_by={sort_by}&from={page}"
);
(url, referer)
}
Target::Tag { slug } => {
let referer = format!("{}/tags/{slug}/", self.url);
let url = format!(
"{referer}?mode=async&function=get_block&block_id=custom_list_videos_common_videos_list&sort_by={sort_by}&from={page}"
);
(url, referer)
}
Target::Model { slug } => {
let referer = format!("{}/models/{slug}/", self.url);
let url = format!(
"{referer}?mode=async&function=get_block&block_id=custom_list_videos_models_videos_list&sort_by={sort_by}&from={page}"
);
(url, referer)
}
Target::Search { query } => {
let path = Self::slugify(query);
let q = query.split_whitespace().collect::<Vec<_>>().join("+");
let referer = format!("{}/search/{path}/", self.url);
let url = format!(
"{referer}?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&q={q}&category_ids=&sort_by={sort_by}&from_videos={page}"
);
(url, referer)
}
}
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![("Referer".to_string(), referer.to_string())]
}
async fn get_videos_for_target(
&self,
cache: VideoCache,
target: &Target,
sort: &str,
page: u32,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let (video_url, referer) = self.build_block_url(target, sort, page);
let old_items = match cache.get(&video_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, CHANNEL_ID, "get_videos_for_target");
let text = match requester
.get_with_headers(&video_url, Self::html_headers(&referer), None)
.await
{
Ok(text) => text,
Err(e) => {
report_provider_error(
CHANNEL_ID,
"get_videos_for_target.request",
&format!("url={video_url}; error={e}"),
)
.await;
return Ok(old_items);
}
};
if text.trim().is_empty() {
return Ok(old_items);
}
let cards = self.parse_video_cards(&text);
if cards.is_empty() {
return Ok(old_items);
}
let video_items = self.enrich_cards(cards, &referer, requester).await;
if !video_items.is_empty() {
cache.remove(&video_url);
cache.insert(video_url.clone(), video_items.clone());
return Ok(video_items);
}
Ok(old_items)
}
fn decode_html_text(text: &str) -> String {
decode(text.as_bytes())
.to_string()
.unwrap_or_else(|_| text.to_string())
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
fn normalize_url(&self, url: &str) -> String {
let trimmed = url.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
if trimmed.starts_with("//") {
return format!("https:{trimmed}");
}
if trimmed.starts_with('/') {
return format!("{}{}", self.url, trimmed);
}
format!("{}/{}", self.url, trimmed.trim_start_matches("./"))
}
/// KVS `get_file` URLs look like `.../10002.mp4/?v-acctoken=...`. Strip the
/// slash sitting between the extension and the query so the path ends in a
/// real media extension (yt-dlp / health-check media detection keys off it).
fn normalize_media_url(raw: &str) -> String {
let mut url = raw.trim().to_string();
for ext in [".mp4/", ".m4v/", ".webm/", ".mov/"] {
if let Some(idx) = url.find(ext) {
let replacement = &ext[..ext.len() - 1];
url.replace_range(idx..idx + ext.len(), replacement);
break;
}
}
url
}
fn id_from_video_url(url: &str) -> Option<String> {
let after = url.split("/video/").nth(1)?;
let id: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
(!id.is_empty()).then_some(id)
}
fn slug_from_model_url(url: &str) -> Option<String> {
url.split("/models/")
.nth(1)?
.split('/')
.next()
.map(str::to_string)
.filter(|slug| !slug.is_empty())
}
fn attr(element: &scraper::ElementRef, names: &[&str]) -> Option<String> {
for name in names {
if let Some(value) = element.value().attr(name) {
let trimmed = value.trim();
if !trimmed.is_empty() && !trimmed.starts_with("data:image/") {
return Some(trimmed.to_string());
}
}
}
None
}
fn parse_video_cards(&self, html: &str) -> Vec<Porn4fansCard> {
let document = Html::parse_document(html);
let Ok(item_sel) = Selector::parse("div.item") else {
return vec![];
};
let link_sel = Selector::parse("a.img-wrap").ok();
let views_sel = Selector::parse("li.video-item.views span").ok();
let duration_sel = Selector::parse("div.duration").ok();
let img_sel = Selector::parse("img").ok();
let source_sel = Selector::parse("picture source").ok();
let preview_sel = Selector::parse("div.preview-video").ok();
let model_sel = Selector::parse("li.video-item.model a").ok();
let mut cards = Vec::new();
let mut seen = HashSet::new();
for item in document.select(&item_sel) {
let Some(link) = link_sel.as_ref().and_then(|sel| item.select(sel).next()) else {
continue;
};
let Some(href) = link.value().attr("href") else {
continue;
};
let page_url = self.normalize_url(href);
let Some(id) = Self::id_from_video_url(&page_url) else {
continue;
};
if !seen.insert(id.clone()) {
continue;
}
// Title: prefer the anchor's title attr, fall back to img alt.
let title_raw = link
.value()
.attr("title")
.map(str::to_string)
.or_else(|| {
img_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|img| img.value().attr("alt").map(str::to_string))
})
.unwrap_or_default();
let title = Self::decode_html_text(&title_raw);
let thumb = img_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|img| Self::attr(&img, &["data-webp", "src", "data-src"]))
.or_else(|| {
source_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|src| Self::attr(&src, &["srcset", "data-srcset"]))
})
.map(|value| self.normalize_url(value.split_whitespace().next().unwrap_or(&value)))
.unwrap_or_default();
let duration = duration_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.map(|d| d.text().collect::<String>())
.and_then(|raw| parse_time_to_seconds(raw.trim()))
.unwrap_or(0) as u32;
let views = views_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.map(|v| v.text().collect::<String>())
.and_then(|raw| parse_abbreviated_number(raw.trim()))
.filter(|count| *count > 0);
let preview = preview_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|p| Self::attr(&p, &["data-src"]))
.map(|raw| self.normalize_url(&raw));
let model = model_sel
.as_ref()
.and_then(|sel| item.select(sel).next());
let uploader = model
.map(|m| Self::decode_html_text(&m.text().collect::<String>()))
.filter(|name| !name.is_empty());
let uploader_href = model.and_then(|m| m.value().attr("href").map(str::to_string));
let uploader_url = uploader_href.as_ref().map(|href| self.normalize_url(href));
let uploader_slug = uploader_href
.as_deref()
.and_then(Self::slug_from_model_url);
cards.push(Porn4fansCard {
id,
title,
page_url,
thumb,
duration,
views,
preview,
uploader,
uploader_url,
uploader_slug,
});
}
cards
}
/// Pull the direct MP4 formats out of the KVS player flashvars.
fn extract_formats_from_page(text: &str, referer: &str) -> Vec<VideoFormat> {
let grab = |key: &str| -> Option<String> {
let re = Regex::new(&format!(r#"{}\s*:\s*'([^']+)'"#, regex::escape(key))).ok()?;
re.captures(text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.filter(|value| !value.is_empty())
};
let mut formats = Vec::new();
let mut seen = HashSet::new();
// (flashvars url key, quality-label key, fallback label) ordered best-first.
let sources = [
("video_alt_url", "video_alt_url_text", "720p"),
("video_url", "video_url_text", "480p"),
];
for (url_key, label_key, fallback) in sources {
let Some(raw_url) = grab(url_key) else {
continue;
};
let url = Self::normalize_media_url(&raw_url);
if !seen.insert(url.clone()) {
continue;
}
let quality = grab(label_key).unwrap_or_else(|| fallback.to_string());
let mut format = VideoFormat::new(url, quality.clone(), "mp4".to_string())
.http_header("Referer".to_string(), referer.to_string());
if let Some(height) = quality
.trim_end_matches('p')
.parse::<u32>()
.ok()
.filter(|h| *h > 0)
{
format = format.height(height);
}
formats.push(format);
}
formats
}
fn extract_tags_from_page(text: &str) -> Vec<String> {
let document = Html::parse_document(text);
// The detail page swaps the class names: `.categories-row` holds tag
// links and `.tags-row` holds category links. Collect both.
let Ok(selector) = Selector::parse(".categories-row a, .tags-row a") else {
return vec![];
};
let mut tags = Vec::new();
let mut seen = HashSet::new();
for link in document.select(&selector) {
let title = Self::decode_html_text(&link.text().collect::<String>());
if title.is_empty() {
continue;
}
let key = title.to_ascii_lowercase();
if seen.insert(key) {
tags.push(title);
}
}
tags
}
async fn enrich_card(
&self,
card: Porn4fansCard,
listing_referer: String,
mut requester: Requester,
) -> VideoItem {
let detail = requester
.get_with_headers(
&card.page_url,
Self::html_headers(&listing_referer),
None,
)
.await
.ok();
let (formats, tags) = match detail.as_deref() {
Some(text) => (
Self::extract_formats_from_page(text, &self.url),
Self::extract_tags_from_page(text),
),
None => (vec![], vec![]),
};
let mut item = VideoItem::new(
card.id,
card.title,
card.page_url.clone(),
CHANNEL_ID.to_string(),
card.thumb,
card.duration,
);
if let Some(views) = card.views {
item = item.views(views);
}
if let Some(uploader) = card.uploader {
item = item.uploader(uploader);
}
if let Some(url) = card.uploader_url {
item = item.uploader_url(url);
}
if let Some(slug) = card.uploader_slug {
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
if !tags.is_empty() {
item = item.tags(tags);
}
if let Some(preview) = card.preview {
item = item.preview(preview);
}
if !formats.is_empty() {
item = item.formats(formats);
}
item
}
async fn enrich_cards(
&self,
cards: Vec<Porn4fansCard>,
listing_referer: &str,
requester: Requester,
) -> Vec<VideoItem> {
stream::iter(cards.into_iter().map(|card| {
let requester = requester.clone();
let referer = listing_referer.to_string();
async move { self.enrich_card(card, referer, requester).await }
}))
.buffered(ENRICH_CONCURRENCY)
.collect::<Vec<_>>()
.await
}
fn spawn_initial_load(&self) {
let url = self.url.clone();
let categories = Arc::clone(&self.categories);
let category_map = Arc::clone(&self.category_map);
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 {
if let Err(error) = Self::load_categories(&url, categories, category_map).await {
report_provider_error_background(
CHANNEL_ID,
"load_categories",
&error.to_string(),
);
}
});
});
}
async fn load_categories(
url: &str,
categories: Arc<RwLock<Vec<FilterOption>>>,
category_map: Arc<RwLock<HashMap<String, String>>>,
) -> Result<()> {
let mut requester = Requester::new();
let category_url = format!("{url}/categories/");
let html = requester
.get_with_headers(&category_url, Self::html_headers(url), None)
.await
.map_err(|error| Error::from(format!("category fetch failed: {error}")))?;
let document = Html::parse_document(&html);
let selector = Selector::parse("#list_categories_categories_list_items a.item")
.map_err(|error| Error::from(format!("category selector failed: {error}")))?;
let mut options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
let mut map = HashMap::new();
for link in document.select(&selector) {
let Some(href) = link.value().attr("href") else {
continue;
};
let Some(slug) = href
.split("/categories/")
.nth(1)
.and_then(|rest| rest.split('/').next())
.filter(|slug| !slug.is_empty())
else {
continue;
};
let title = link
.value()
.attr("title")
.map(Self::decode_html_text)
.filter(|title| !title.is_empty())
.unwrap_or_else(|| Self::decode_html_text(&link.text().collect::<String>()));
if title.is_empty() {
continue;
}
let normalized = Self::normalize_title(&title);
if normalized.is_empty() || map.contains_key(&normalized) {
continue;
}
options.push(FilterOption {
id: normalized.clone(),
title,
});
map.insert(normalized, slug.to_string());
}
if options.len() > 1 {
if let Ok(mut guard) = categories.write() {
*guard = options;
}
if let Ok(mut guard) = category_map.write() {
*guard = map;
}
}
Ok(())
}
}
#[async_trait]
impl Provider for Porn4fansProvider {
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.target_from_request(query.as_deref(), options.categories.as_deref());
match self
.get_videos_for_target(cache, &target, &sort, page, options)
.await
{
Ok(videos) => videos,
Err(e) => {
report_provider_error(
CHANNEL_ID,
"get_videos",
&format!("page={page}; target={target:?}; error={e}"),
)
.await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_collapses_separators() {
assert_eq!(Porn4fansProvider::slugify("Big Black Cock"), "big-black-cock");
assert_eq!(Porn4fansProvider::slugify(" big__ass "), "big-ass");
}
#[test]
fn builds_latest_block_url() {
let provider = Porn4fansProvider::new();
let (url, referer) = provider.build_block_url(&Target::Latest, "new", 2);
assert_eq!(referer, "https://www.porn4fans.com/onlyfans-videos/");
assert_eq!(
url,
"https://www.porn4fans.com/onlyfans-videos/?mode=async&function=get_block&block_id=custom_list_videos_latest_videos_list&sort_by=post_date&from=2"
);
}
#[test]
fn builds_search_block_url() {
let provider = Porn4fansProvider::new();
let (url, referer) = provider.build_block_url(
&Target::Search {
query: "big ass".to_string(),
},
"popular",
3,
);
assert_eq!(referer, "https://www.porn4fans.com/search/big-ass/");
assert_eq!(
url,
"https://www.porn4fans.com/search/big-ass/?mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&q=big+ass&category_ids=&sort_by=video_viewed&from_videos=3"
);
}
#[test]
fn category_block_url_uses_common_list() {
let provider = Porn4fansProvider::new();
let (url, _) = provider.build_block_url(
&Target::Category {
slug: "asian".to_string(),
},
"new",
1,
);
assert_eq!(
url,
"https://www.porn4fans.com/categories/asian/?mode=async&function=get_block&block_id=custom_list_videos_common_videos_list&sort_by=post_date&from=1"
);
}
#[test]
fn normalizes_kvs_media_url() {
assert_eq!(
Porn4fansProvider::normalize_media_url(
"https://www.porn4fans.com/get_file/4/abc/10000/10002/10002.mp4/?v-acctoken=zzz"
),
"https://www.porn4fans.com/get_file/4/abc/10000/10002/10002.mp4?v-acctoken=zzz"
);
assert_eq!(
Porn4fansProvider::normalize_media_url(
"https://www.porn4fans.com/get_file/4/abc/10000/10002/10002_720p.mp4/"
),
"https://www.porn4fans.com/get_file/4/abc/10000/10002/10002_720p.mp4"
);
}
#[test]
fn extracts_formats_from_flashvars() {
let html = r#"
<script>
var flashvars = {
video_url: 'https://www.porn4fans.com/get_file/4/h1/10000/10002/10002.mp4/?v-acctoken=a',
video_url_text: '480p',
video_alt_url: 'https://www.porn4fans.com/get_file/4/h2/10000/10002/10002_720p.mp4/?v-acctoken=b',
video_alt_url_text: '720p',
license_code: '$546156418018655'
};
</script>
"#;
let formats = Porn4fansProvider::extract_formats_from_page(html, "https://www.porn4fans.com/");
assert_eq!(formats.len(), 2);
// best-first: 720p before 480p
assert!(formats[0].url.ends_with("10002_720p.mp4?v-acctoken=b"));
assert!(formats[1].url.ends_with("10002.mp4?v-acctoken=a"));
}
#[test]
fn parses_async_fragment_card() {
let provider = Porn4fansProvider::new();
let html = r##"
<div id="custom_list_videos_latest_videos_list_items">
<div class="item ">
<div class="item-link">
<a class="img-wrap video" href="https://www.porn4fans.com/video/10002/blasianflexcouple-gets-banged-against-the-wall/" title="Blasianflexcouple Gets Banged Against The Wall">
<ul class="video-items for-grid">
<li class="video-item views"><i></i><span>103</span></li>
<li class="video-item rating"><i></i><span>0</span></li>
</ul>
<div class="duration">24:37</div>
<picture>
<source srcset="https://www.porn4fans.com/contents/videos_screenshots/10000/10002/800x450/1.jpg" type="image/webp">
<img class="thumb " src="https://www.porn4fans.com/contents/videos_screenshots/10000/10002/800x450/1.jpg" data-webp="https://www.porn4fans.com/contents/videos_screenshots/10000/10002/800x450/1.jpg" alt="Blasianflexcouple Gets Banged Against The Wall" />
</picture>
<div class="preview-video" data-src="https://www.porn4fans.com/get_file/4/x/10000/10002/10002_preview_new.mp4/"></div>
</a>
<div class="video-item-bottom">
<a class="video-text" href="https://www.porn4fans.com/video/10002/blasianflexcouple-gets-banged-against-the-wall/">Blasianflexcouple Gets Banged Against The Wall</a>
<div class="video-items">
<li class="video-item model single"><a href="https://www.porn4fans.com/models/nina-lee/">Nina Lee</a></li>
</div>
</div>
</div>
</div>
</div>
"##;
let cards = provider.parse_video_cards(html);
assert_eq!(cards.len(), 1);
let card = &cards[0];
assert_eq!(card.id, "10002");
assert_eq!(
card.page_url,
"https://www.porn4fans.com/video/10002/blasianflexcouple-gets-banged-against-the-wall/"
);
assert_eq!(
card.thumb,
"https://www.porn4fans.com/contents/videos_screenshots/10000/10002/800x450/1.jpg"
);
assert_eq!(card.duration, 1477);
assert_eq!(card.views, Some(103));
assert_eq!(card.uploader.as_deref(), Some("Nina Lee"));
assert_eq!(card.uploader_slug.as_deref(), Some("nina-lee"));
assert!(card.preview.is_some());
}
}