Files
hottub/src/api.rs
2026-03-10 18:45:32 +00:00

404 lines
13 KiB
Rust

use crate::providers::{
ALL_PROVIDERS, DynProvider, panic_payload_to_string, report_provider_error,
run_provider_guarded,
};
use crate::util::cache::VideoCache;
use crate::util::discord::send_discord_error_report;
use crate::util::proxy::{Proxy, all_proxies_snapshot};
use crate::util::requester::Requester;
use crate::{DbPool, db, status::*, videos::*};
use ntex::http::header;
use ntex::web;
use ntex::web::HttpRequest;
use std::cmp::Ordering;
use std::io;
use tokio::task;
#[derive(Debug, Clone)]
pub struct ClientVersion {
version: u32,
subversion: u32,
name: String,
}
impl ClientVersion {
pub fn new(version: u32, subversion: u32, name: String) -> ClientVersion {
ClientVersion {
version,
subversion,
name,
}
}
pub fn parse(input: &str) -> Option<Self> {
// Example input: "Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0 0.002478"
let first_part = input.split_whitespace().next()?;
let mut name_version = first_part.splitn(2, '/');
let name = name_version.next()?;
let version_str = name_version.next()?;
// Find the index where the numeric part ends
let split_idx = version_str
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(version_str.len());
let (v_num, v_alpha) = version_str.split_at(split_idx);
// Parse the numeric version
let version = v_num.parse::<u32>().ok()?;
// Convert the first character of the subversion to u32 (ASCII value),
// or 0 if it doesn't exist.
let subversion = v_alpha.chars().next().map(|ch| ch as u32).unwrap_or(0);
Some(Self {
version,
subversion,
name: name.to_string(),
})
}
}
// Implement comparisons
impl PartialEq for ClientVersion {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for ClientVersion {}
impl PartialOrd for ClientVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ClientVersion {
fn cmp(&self, other: &Self) -> Ordering {
self.version
.cmp(&other.version)
.then_with(|| self.subversion.cmp(&other.subversion))
}
}
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/status")
.route(web::post().to(status))
.route(web::get().to(status)),
)
.service(
web::resource("/videos")
// .route(web::get().to(videos_get))
.route(web::post().to(videos_post)),
)
.service(web::resource("/test").route(web::get().to(test)))
.service(web::resource("/proxies").route(web::get().to(proxies)));
}
async fn status(req: HttpRequest) -> Result<impl web::Responder, web::Error> {
let clientversion: ClientVersion = match req.headers().get("User-Agent") {
Some(v) => match v.to_str() {
Ok(useragent) => ClientVersion::parse(useragent)
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
},
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
};
println!(
"Received status request with client version: {:?}",
clientversion
);
let host = req
.headers()
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.to_string();
let public_url_base = format!("{}://{}", req.connection_info().scheme(), host);
let mut status = Status::new();
for (provider_name, provider) in ALL_PROVIDERS.iter() {
let channel_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
provider.get_channel(clientversion.clone())
}));
match channel_result {
Ok(Some(mut channel)) => {
if channel.favicon.starts_with('/') {
channel.favicon = format!("{}{}", public_url_base, channel.favicon);
}
status.add_channel(channel)
}
Ok(None) => {}
Err(payload) => {
let panic_msg = panic_payload_to_string(payload);
report_provider_error(provider_name, "status.get_channel", &panic_msg).await;
}
}
}
status.iconUrl = format!("{}/favicon.ico", public_url_base).to_string();
Ok(web::HttpResponse::Ok().json(&status))
}
async fn videos_post(
mut video_request: web::types::Json<VideosRequest>,
cache: web::types::State<VideoCache>,
pool: web::types::State<DbPool>,
requester: web::types::State<Requester>,
req: HttpRequest,
) -> Result<impl web::Responder, web::Error> {
let clientversion: ClientVersion = match req.headers().get("User-Agent") {
Some(v) => match v.to_str() {
Ok(useragent) => ClientVersion::parse(useragent)
.unwrap_or_else(|| ClientVersion::new(999, 0, "Hot%20Tub".to_string())),
Err(_) => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
},
_ => ClientVersion::new(999, 0, "Hot%20Tub".to_string()),
};
match video_request.query.as_deref() {
Some(query) if query.starts_with("#") => {
video_request.query = Some(query.trim_start_matches("#").to_string());
}
_ => {}
}
let requester = requester.get_ref().clone();
// Ensure "videos" table exists with two string columns.
match pool.get() {
Ok(mut conn) => match db::has_table(&mut conn, "videos") {
Ok(false) => {
if let Err(e) = db::create_table(
&mut conn,
"CREATE TABLE videos (id TEXT NOT NULL, url TEXT NOT NULL);",
) {
report_provider_error("db", "videos_post.create_table", &e.to_string()).await;
}
}
Ok(true) => {}
Err(e) => {
report_provider_error("db", "videos_post.has_table", &e.to_string()).await;
}
},
Err(e) => {
report_provider_error("db", "videos_post.pool_get", &e.to_string()).await;
}
}
let mut videos = Videos {
pageInfo: PageInfo {
hasNextPage: true,
resultsPerPage: 10,
},
items: vec![],
};
let channel: String = video_request
.channel
.as_deref()
.unwrap_or("all")
.to_string();
let sort: String = video_request.sort.as_deref().unwrap_or("date").to_string();
let mut query: Option<String> = video_request.query.clone();
if video_request.query.as_deref() == Some("") {
query = None;
}
let page: u8 = video_request
.page
.as_ref()
.and_then(|value| value.to_u8())
.unwrap_or(1);
let perPage: u8 = video_request
.perPage
.as_ref()
.and_then(|value| value.to_u8())
.unwrap_or(10);
let featured = video_request
.featured
.as_deref()
.unwrap_or("all")
.to_string();
let provider = get_provider(channel.as_str())
.ok_or_else(|| web::error::ErrorBadRequest("Invalid channel".to_string()))?;
let category = video_request
.category
.as_deref()
.unwrap_or("all")
.to_string();
let sites = if channel == "all" {
video_request
.all_provider_sites
.as_deref()
.or(video_request.sites.as_deref())
.unwrap_or("")
.to_string()
} else {
video_request.sites.as_deref().unwrap_or("").to_string()
};
let filter = video_request.filter.as_deref().unwrap_or("new").to_string();
let language = video_request
.language
.as_deref()
.unwrap_or("en")
.to_string();
let network = video_request.networks.as_deref().unwrap_or("").to_string();
let stars = video_request.stars.as_deref().unwrap_or("").to_string();
let categories = video_request
.categories
.as_deref()
.unwrap_or("")
.to_string();
let duration = video_request.duration.as_deref().unwrap_or("").to_string();
let sexuality = video_request.sexuality.as_deref().unwrap_or("").to_string();
let public_url_base = format!(
"{}://{}",
req.connection_info().scheme(),
req.connection_info().host()
);
let options = ServerOptions {
featured: Some(featured),
category: Some(category),
sites: Some(sites),
filter: Some(filter),
language: Some(language),
public_url_base: Some(public_url_base),
requester: Some(requester),
network: Some(network),
stars: Some(stars),
categories: Some(categories),
duration: Some(duration),
sort: Some(sort.clone()),
sexuality: Some(sexuality),
};
let mut video_items = run_provider_guarded(
&channel,
"videos_post.get_videos",
provider.get_videos(
cache.get_ref().clone(),
pool.get_ref().clone(),
sort.clone(),
query.clone(),
page.to_string(),
perPage.to_string(),
options.clone(),
),
)
.await;
// There is a bug in Hottub38 that makes the client error for a 403-url even though formats work fine
if clientversion == ClientVersion::new(38, 0, "Hot%20Tub".to_string()) {
// filter out videos without preview for old clients
video_items = video_items
.into_iter()
.filter_map(|video| {
let last_url = video
.formats
.as_ref()
.and_then(|formats| formats.last().map(|f| f.url.clone()));
if let Some(url) = last_url {
let mut v = video;
v.url = url;
return Some(v);
}
Some(video)
})
.collect();
}
videos.items = video_items.clone();
if video_items.len() == 0 {
videos.pageInfo = PageInfo {
hasNextPage: false,
resultsPerPage: 10,
}
}
//###
let next_page = page.to_string().parse::<i32>().unwrap_or(1) + 1;
let provider_clone = provider.clone();
let cache_clone = cache.get_ref().clone();
let pool_clone = pool.get_ref().clone();
let sort_clone = sort.clone();
let query_clone = query.clone();
let per_page_clone = perPage.to_string();
let options_clone = options.clone();
let channel_clone = channel.clone();
task::spawn_local(async move {
// if let AnyProvider::Spankbang(_) = provider_clone {
// // Spankbang has a delay for the next page
// ntex::time::sleep(ntex::time::Seconds(80)).await;
// }
let _ = run_provider_guarded(
&channel_clone,
"videos_post.prefetch_next_page",
provider_clone.get_videos(
cache_clone,
pool_clone,
sort_clone,
query_clone,
next_page.to_string(),
per_page_clone,
options_clone,
),
)
.await;
});
//###
for video in videos.items.iter_mut() {
if video.duration <= 120 {
let mut preview_url = video.url.clone();
if let Some(x) = &video.formats {
if let Some(first) = x.first() {
preview_url = first.url.clone();
}
}
video.preview = Some(preview_url);
}
}
Ok(web::HttpResponse::Ok().json(&videos))
}
pub fn get_provider(channel: &str) -> Option<DynProvider> {
ALL_PROVIDERS.get(channel).cloned()
}
pub async fn test() -> Result<impl web::Responder, web::Error> {
let e = io::Error::new(io::ErrorKind::Other, "test error");
let _ = send_discord_error_report(
e.to_string(),
Some("chain_str".to_string()),
Some("Context"),
Some("xtra info"),
file!(),
line!(),
module_path!(),
)
.await;
Ok(web::HttpResponse::Ok())
}
pub async fn proxies() -> Result<impl web::Responder, web::Error> {
let proxies = all_proxies_snapshot().await.unwrap_or_default();
let mut by_protocol: std::collections::BTreeMap<String, Vec<Proxy>> =
std::collections::BTreeMap::new();
for proxy in proxies {
by_protocol
.entry(proxy.protocol.clone())
.or_default()
.push(proxy);
}
for proxies in by_protocol.values_mut() {
proxies.sort_by(|a, b| {
a.host
.cmp(&b.host)
.then(a.port.cmp(&b.port))
.then(a.username.cmp(&b.username))
.then(a.password.cmp(&b.password))
});
}
Ok(web::HttpResponse::Ok().json(&by_protocol))
}