Files
hottub/src/providers/mod.rs
2026-03-21 19:29:30 +00:00

680 lines
20 KiB
Rust

use async_trait::async_trait;
use futures::FutureExt;
use once_cell::sync::Lazy;
use rustc_hash::FxHashMap as HashMap;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use crate::{
DbPool,
api::ClientVersion,
status::{Channel, ChannelGroup, ChannelView, Status, StatusResponse},
util::{cache::VideoCache, discord::send_discord_error_report, requester::Requester},
videos::{ServerOptions, VideoItem},
};
pub mod all;
pub mod hanime;
pub mod homoxxx;
pub mod okporn;
pub mod okxxx;
pub mod perfectgirls;
pub mod perverzija;
pub mod pmvhaven;
pub mod pornhat;
pub mod pornhub;
pub mod redtube;
pub mod rule34video;
pub mod spankbang;
// pub mod hentaimoon;
pub mod beeg;
pub mod missav;
pub mod omgxxx;
pub mod paradisehill;
pub mod porn00;
pub mod porn4fans;
pub mod porndish;
pub mod pornzog;
pub mod shooshtime;
pub mod sxyprn;
pub mod tnaflix;
pub mod tokyomotion;
pub mod viralxxxporn;
pub mod vrporn;
pub mod xfree;
pub mod xxthots;
pub mod yesporn;
pub mod youjizz;
// pub mod pornxp;
pub mod chaturbate;
pub mod freepornvideosxxx;
pub mod heavyfetish;
pub mod hentaihaven;
pub mod hqporner;
pub mod hsex;
pub mod hypnotube;
pub mod javtiful;
pub mod noodlemagazine;
pub mod pimpbunny;
pub mod rule34gen;
pub mod xxdbx;
// pub mod tube8;
// convenient alias
pub type DynProvider = Arc<dyn Provider>;
#[derive(Clone, Copy)]
pub struct ProviderChannelMetadata {
pub group_id: &'static str,
pub tags: &'static [&'static str],
}
pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|| {
let mut m = HashMap::default();
m.insert("all", Arc::new(all::AllProvider::new()) as DynProvider);
m.insert(
"perverzija",
Arc::new(perverzija::PerverzijaProvider::new()) as DynProvider,
);
m.insert(
"hanime",
Arc::new(hanime::HanimeProvider::new()) as DynProvider,
);
m.insert(
"pornhub",
Arc::new(pornhub::PornhubProvider::new()) as DynProvider,
);
m.insert(
"spankbang",
Arc::new(spankbang::SpankbangProvider::new()) as DynProvider,
);
m.insert(
"rule34video",
Arc::new(rule34video::Rule34videoProvider::new()) as DynProvider,
);
m.insert(
"redtube",
Arc::new(redtube::RedtubeProvider::new()) as DynProvider,
);
m.insert(
"okporn",
Arc::new(okporn::OkpornProvider::new()) as DynProvider,
);
m.insert(
"pornhat",
Arc::new(pornhat::PornhatProvider::new()) as DynProvider,
);
m.insert(
"perfectgirls",
Arc::new(perfectgirls::PerfectgirlsProvider::new()) as DynProvider,
);
m.insert(
"okxxx",
Arc::new(okxxx::OkxxxProvider::new()) as DynProvider,
);
m.insert(
"homoxxx",
Arc::new(homoxxx::HomoxxxProvider::new()) as DynProvider,
);
m.insert(
"missav",
Arc::new(missav::MissavProvider::new()) as DynProvider,
);
m.insert(
"xxthots",
Arc::new(xxthots::XxthotsProvider::new()) as DynProvider,
);
m.insert(
"yesporn",
Arc::new(yesporn::YespornProvider::new()) as DynProvider,
);
m.insert(
"sxyprn",
Arc::new(sxyprn::SxyprnProvider::new()) as DynProvider,
);
m.insert(
"porn00",
Arc::new(porn00::Porn00Provider::new()) as DynProvider,
);
m.insert(
"youjizz",
Arc::new(youjizz::YoujizzProvider::new()) as DynProvider,
);
m.insert(
"paradisehill",
Arc::new(paradisehill::ParadisehillProvider::new()) as DynProvider,
);
m.insert(
"porn4fans",
Arc::new(porn4fans::Porn4fansProvider::new()) as DynProvider,
);
m.insert(
"porndish",
Arc::new(porndish::PorndishProvider::new()) as DynProvider,
);
m.insert(
"shooshtime",
Arc::new(shooshtime::ShooshtimeProvider::new()) as DynProvider,
);
m.insert(
"pornzog",
Arc::new(pornzog::PornzogProvider::new()) as DynProvider,
);
m.insert(
"omgxxx",
Arc::new(omgxxx::OmgxxxProvider::new()) as DynProvider,
);
m.insert("beeg", Arc::new(beeg::BeegProvider::new()) as DynProvider);
m.insert(
"tnaflix",
Arc::new(tnaflix::TnaflixProvider::new()) as DynProvider,
);
m.insert(
"tokyomotion",
Arc::new(tokyomotion::TokyomotionProvider::new()) as DynProvider,
);
m.insert(
"viralxxxporn",
Arc::new(viralxxxporn::ViralxxxpornProvider::new()) as DynProvider,
);
m.insert(
"vrporn",
Arc::new(vrporn::VrpornProvider::new()) as DynProvider,
);
// m.insert("pornxp", Arc::new(pornxp::PornxpProvider::new()) as DynProvider);
m.insert(
"rule34gen",
Arc::new(rule34gen::Rule34genProvider::new()) as DynProvider,
);
m.insert(
"xxdbx",
Arc::new(xxdbx::XxdbxProvider::new()) as DynProvider,
);
m.insert(
"xfree",
Arc::new(xfree::XfreeProvider::new()) as DynProvider,
);
m.insert(
"hqporner",
Arc::new(hqporner::HqpornerProvider::new()) as DynProvider,
);
m.insert(
"pmvhaven",
Arc::new(pmvhaven::PmvhavenProvider::new()) as DynProvider,
);
m.insert(
"noodlemagazine",
Arc::new(noodlemagazine::NoodlemagazineProvider::new()) as DynProvider,
);
m.insert(
"pimpbunny",
Arc::new(pimpbunny::PimpbunnyProvider::new()) as DynProvider,
);
m.insert(
"javtiful",
Arc::new(javtiful::JavtifulProvider::new()) as DynProvider,
);
m.insert(
"hypnotube",
Arc::new(hypnotube::HypnotubeProvider::new()) as DynProvider,
);
m.insert(
"freepornvideosxxx",
Arc::new(freepornvideosxxx::FreepornvideosxxxProvider::new()) as DynProvider,
);
m.insert(
"heavyfetish",
Arc::new(heavyfetish::HeavyfetishProvider::new()) as DynProvider,
);
m.insert("hsex", Arc::new(hsex::HsexProvider::new()) as DynProvider);
m.insert(
"hentaihaven",
Arc::new(hentaihaven::HentaihavenProvider::new()) as DynProvider,
);
m.insert(
"chaturbate",
Arc::new(chaturbate::ChaturbateProvider::new()) as DynProvider,
);
// m.insert("tube8", Arc::new(tube8::Tube8Provider::new()) as DynProvider);
// add more here as you migrate them
m
});
pub fn init_providers_now() {
// Idempotent & thread-safe: runs the Lazy init exactly once.
Lazy::force(&ALL_PROVIDERS);
}
pub fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
return (*s).to_string();
}
if let Some(s) = payload.downcast_ref::<String>() {
return s.clone();
}
"unknown panic payload".to_string()
}
pub async fn run_provider_guarded<F>(provider_name: &str, context: &str, fut: F) -> Vec<VideoItem>
where
F: Future<Output = Vec<VideoItem>>,
{
match AssertUnwindSafe(fut).catch_unwind().await {
Ok(videos) => videos,
Err(payload) => {
let panic_msg = panic_payload_to_string(payload);
let _ = send_discord_error_report(
format!("Provider panic: {}", provider_name),
None,
Some("Provider Guard"),
Some(&format!("context={}; panic={}", context, panic_msg)),
file!(),
line!(),
module_path!(),
)
.await;
vec![]
}
}
}
pub async fn report_provider_error(provider_name: &str, context: &str, msg: &str) {
let _ = send_discord_error_report(
format!("Provider error: {}", provider_name),
None,
Some("Provider Guard"),
Some(&format!("context={}; error={}", context, msg)),
file!(),
line!(),
module_path!(),
)
.await;
}
pub fn report_provider_error_background(provider_name: &str, context: &str, msg: &str) {
let provider_name = provider_name.to_string();
let context = context.to_string();
let msg = msg.to_string();
tokio::spawn(async move {
report_provider_error(&provider_name, &context, &msg).await;
});
}
pub fn requester_or_default(
options: &ServerOptions,
provider_name: &str,
context: &str,
) -> Requester {
match options.requester.clone() {
Some(requester) => requester,
None => {
report_provider_error_background(
provider_name,
context,
"ServerOptions.requester missing; using default Requester",
);
Requester::new()
}
}
}
pub fn strip_url_scheme(url: &str) -> String {
url.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url)
.trim_start_matches('/')
.to_string()
}
pub fn build_proxy_url(options: &ServerOptions, proxy: &str, target: &str) -> String {
let target = target.trim_start_matches('/');
let base = options
.public_url_base
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
if base.is_empty() {
format!("/proxy/{proxy}/{target}")
} else {
format!("{base}/proxy/{proxy}/{target}")
}
}
fn channel_metadata_for(id: &str) -> Option<ProviderChannelMetadata> {
match id {
"all" | "hottub" => Some(all::CHANNEL_METADATA),
"pornhub" => Some(pornhub::CHANNEL_METADATA),
"spankbang" => Some(spankbang::CHANNEL_METADATA),
"rule34video" => Some(rule34video::CHANNEL_METADATA),
"redtube" => Some(redtube::CHANNEL_METADATA),
"okporn" => Some(okporn::CHANNEL_METADATA),
"pornhat" => Some(pornhat::CHANNEL_METADATA),
"perfectgirls" => Some(perfectgirls::CHANNEL_METADATA),
"okxxx" => Some(okxxx::CHANNEL_METADATA),
"homoxxx" => Some(homoxxx::CHANNEL_METADATA),
"missav" => Some(missav::CHANNEL_METADATA),
"xxthots" => Some(xxthots::CHANNEL_METADATA),
"yesporn" => Some(yesporn::CHANNEL_METADATA),
"sxyprn" => Some(sxyprn::CHANNEL_METADATA),
"porn00" => Some(porn00::CHANNEL_METADATA),
"youjizz" => Some(youjizz::CHANNEL_METADATA),
"paradisehill" => Some(paradisehill::CHANNEL_METADATA),
"porn4fans" => Some(porn4fans::CHANNEL_METADATA),
"porndish" => Some(porndish::CHANNEL_METADATA),
"shooshtime" => Some(shooshtime::CHANNEL_METADATA),
"pornzog" => Some(pornzog::CHANNEL_METADATA),
"omgxxx" => Some(omgxxx::CHANNEL_METADATA),
"beeg" => Some(beeg::CHANNEL_METADATA),
"tnaflix" => Some(tnaflix::CHANNEL_METADATA),
"tokyomotion" => Some(tokyomotion::CHANNEL_METADATA),
"viralxxxporn" => Some(viralxxxporn::CHANNEL_METADATA),
"vrporn" => Some(vrporn::CHANNEL_METADATA),
"rule34gen" => Some(rule34gen::CHANNEL_METADATA),
"xxdbx" => Some(xxdbx::CHANNEL_METADATA),
"xfree" => Some(xfree::CHANNEL_METADATA),
"hqporner" => Some(hqporner::CHANNEL_METADATA),
"pmvhaven" => Some(pmvhaven::CHANNEL_METADATA),
"noodlemagazine" => Some(noodlemagazine::CHANNEL_METADATA),
"pimpbunny" => Some(pimpbunny::CHANNEL_METADATA),
"javtiful" => Some(javtiful::CHANNEL_METADATA),
"hypnotube" => Some(hypnotube::CHANNEL_METADATA),
"freepornvideosxxx" => Some(freepornvideosxxx::CHANNEL_METADATA),
"heavyfetish" => Some(heavyfetish::CHANNEL_METADATA),
"hsex" => Some(hsex::CHANNEL_METADATA),
"hentaihaven" => Some(hentaihaven::CHANNEL_METADATA),
"hanime" => Some(hanime::CHANNEL_METADATA),
"perverzija" => Some(perverzija::CHANNEL_METADATA),
"chaturbate" => Some(chaturbate::CHANNEL_METADATA),
_ => None,
}
}
fn channel_group_title(group_id: &str) -> &'static str {
match group_id {
"meta-search" => "Meta Search",
"mainstream-tube" => "Mainstream Tube",
"studio-network" => "Studio & Network",
"amateur-homemade" => "Amateur & Homemade",
"onlyfans" => "OnlyFans",
"chinese" => "Chinese",
"jav" => "JAV",
"fetish-kink" => "Fetish & Kink",
"hentai-animation" => "Hentai & Animation",
"ai" => "AI",
"gay-male" => "Gay & Male",
"live-cams" => "Live Cams",
"pmv-compilation" => "PMV & Compilation",
_ => "Other",
}
}
fn channel_group_order(group_id: &str) -> usize {
match group_id {
"meta-search" => 0,
"mainstream-tube" => 1,
"studio-network" => 2,
"onlyfans" => 3,
"chinese" => 4,
"jav" => 5,
"fetish-kink" => 6,
"hentai-animation" => 7,
"ai" => 8,
"gay-male" => 9,
"live-cams" => 10,
"pmv-compilation" => 11,
_ => 99,
}
}
pub fn decorate_channel(channel: Channel) -> ChannelView {
let metadata = channel_metadata_for(&channel.id);
ChannelView {
id: channel.id,
name: channel.name,
description: channel.description,
premium: channel.premium,
favicon: channel.favicon,
status: channel.status,
categories: channel.categories,
options: channel.options,
nsfw: channel.nsfw,
groupKey: metadata.map(|value| value.group_id.to_string()),
sortOrder: None,
tags: metadata.map(|value| {
value
.tags
.iter()
.take(3)
.map(|tag| (*tag).to_string())
.collect()
}),
cacheDuration: channel.cacheDuration,
}
}
pub fn build_channel_groups(channels: &[ChannelView]) -> Vec<ChannelGroup> {
let mut groups = Vec::new();
let mut group_ids = channels
.iter()
.filter_map(|channel| channel.groupKey.clone())
.collect::<Vec<_>>();
group_ids.sort_by_key(|group_id| (channel_group_order(group_id), group_id.clone()));
group_ids.dedup();
for group_id in group_ids {
let mut grouped_channels = channels
.iter()
.filter(|channel| channel.groupKey.as_deref() == Some(group_id.as_str()))
.collect::<Vec<_>>();
grouped_channels.sort_by(|a, b| {
(a.sortOrder.unwrap_or(u32::MAX), &a.name, &a.id).cmp(&(
b.sortOrder.unwrap_or(u32::MAX),
&b.name,
&b.id,
))
});
let channel_ids = grouped_channels
.into_iter()
.map(|channel| channel.id.clone())
.collect::<Vec<_>>();
groups.push(ChannelGroup {
id: group_id.clone(),
title: channel_group_title(&group_id).to_string(),
channelIds: channel_ids,
});
}
groups
}
fn assign_channel_sort_order(channels: &mut [ChannelView]) {
let mut ordered = channels
.iter()
.enumerate()
.map(|(index, channel)| {
(
index,
channel.groupKey.clone(),
channel.name.to_ascii_lowercase(),
channel.id.to_ascii_lowercase(),
)
})
.collect::<Vec<_>>();
ordered.sort_by(|a, b| {
let a_group = a.1.as_deref().unwrap_or("");
let b_group = b.1.as_deref().unwrap_or("");
(channel_group_order(a_group), a_group, &a.2, &a.3).cmp(&(
channel_group_order(b_group),
b_group,
&b.2,
&b.3,
))
});
for (sort_index, (channel_index, _, _, _)) in ordered.into_iter().enumerate() {
channels[channel_index].sortOrder = Some((sort_index + 1) as u32);
}
}
pub fn build_status_response(status: Status) -> StatusResponse {
let mut channels = status
.channels
.into_iter()
.map(decorate_channel)
.collect::<Vec<_>>();
assign_channel_sort_order(&mut channels);
let channelGroups = build_channel_groups(&channels);
StatusResponse {
id: status.id,
name: status.name,
subtitle: status.subtitle,
description: status.description,
iconUrl: status.iconUrl,
color: status.color,
status: status.status,
notices: status.notices,
channels,
channelGroups,
subscription: status.subscription,
nsfw: status.nsfw,
categories: status.categories,
options: status.options,
filtersFooter: status.filtersFooter,
}
}
#[async_trait]
pub trait Provider: Send + Sync {
async fn get_videos(
&self,
cache: VideoCache,
pool: DbPool,
sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem>;
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
println!(
"Getting channel for placeholder with client version: {:?}",
clientversion
);
let _ = clientversion;
Some(Channel {
id: "placeholder".to_string(),
name: "PLACEHOLDER".to_string(),
description: "PLACEHOLDER FOR PARENT CLASS".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=missav.ws".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![],
nsfw: true,
cacheDuration: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::status::ChannelOption;
fn base_channel(id: &str) -> Channel {
Channel {
id: id.to_string(),
name: id.to_string(),
description: String::new(),
premium: false,
favicon: String::new(),
status: "active".to_string(),
categories: vec![],
options: Vec::<ChannelOption>::new(),
nsfw: true,
cacheDuration: None,
}
}
#[test]
fn decorates_channel_with_group_and_tags() {
let channel = decorate_channel(base_channel("hsex"));
assert_eq!(channel.groupKey.as_deref(), Some("chinese"));
assert_eq!(channel.sortOrder, None);
assert_eq!(
channel.tags.as_deref(),
Some(
&[
"amateur".to_string(),
"chinese".to_string(),
"homemade".to_string(),
][..]
)
);
}
#[test]
fn builds_group_index() {
let channels = vec![
decorate_channel(base_channel("all")),
decorate_channel(base_channel("hsex")),
decorate_channel(base_channel("missav")),
];
let groups = build_channel_groups(&channels);
assert_eq!(groups[0].id, "meta-search");
assert_eq!(groups[1].id, "chinese");
assert_eq!(groups[2].id, "jav");
}
#[test]
fn reflects_updated_group_moves() {
assert_eq!(
decorate_channel(base_channel("perverzija"))
.groupKey
.as_deref(),
Some("studio-network")
);
assert_eq!(
decorate_channel(base_channel("rule34gen"))
.groupKey
.as_deref(),
Some("ai")
);
}
#[test]
fn status_response_uses_documented_group_keys() {
let mut status = Status::new();
status.channels = vec![
base_channel("missav"),
base_channel("hsex"),
base_channel("all"),
];
let json = serde_json::to_value(build_status_response(status)).expect("valid status json");
let channels = json["channels"].as_array().expect("channels array");
let all_channel = channels
.iter()
.find(|channel| channel["id"] == "all")
.expect("all channel present");
assert_eq!(all_channel["groupKey"], "meta-search");
assert!(all_channel.get("group").is_none());
assert!(all_channel["sortOrder"].is_number());
let groups = json["channelGroups"].as_array().expect("group array");
let meta_group = groups
.iter()
.find(|group| group["id"] == "meta-search")
.expect("meta group present");
assert_eq!(meta_group["channelIds"], serde_json::json!(["all"]));
assert!(meta_group.get("channels").is_none());
}
}