Files
hottub/src/providers/tnaflix.rs
2026-03-18 12:13:28 +00:00

353 lines
12 KiB
Rust

use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::Provider;
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::discord::{format_error_chain, send_discord_error_report};
use crate::util::parse_abbreviated_number;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use htmlentity::entity::{ICodedDataTrait, decode};
use std::vec;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["mainstream", "legacy", "studio"],
};
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
}
#[derive(Debug, Clone)]
pub struct TnaflixProvider {
url: String,
}
impl TnaflixProvider {
pub fn new() -> Self {
TnaflixProvider {
url: "https://www.tnaflix.com".to_string(),
}
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: "tnaflix".to_string(),
name: "TnAflix".to_string(),
description: "Just Tits and Ass".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=www.tnaflix.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: "new".into(),
title: "New".into(),
},
FilterOption {
id: "featured".into(),
title: "Featured".into(),
},
FilterOption {
id: "toprated".into(),
title: "Top Rated".into(),
},
],
multiSelect: false,
},
ChannelOption {
id: "duration".to_string(),
title: "Duration".to_string(),
description: "Length of the Videos".to_string(),
systemImage: "timer".to_string(),
colorName: "green".to_string(),
options: vec![
FilterOption {
id: "all".into(),
title: "All".into(),
},
FilterOption {
id: "short".into(),
title: "Short (1-3 min)".into(),
},
FilterOption {
id: "medium".into(),
title: "Medium (3-10 min)".into(),
},
FilterOption {
id: "long".into(),
title: "Long (10-30 min)".into(),
},
FilterOption {
id: "full".into(),
title: "Full length (30+ min)".into(),
},
],
multiSelect: false,
},
],
nsfw: true,
cacheDuration: None,
}
}
async fn get(
&self,
cache: VideoCache,
page: u8,
sort: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let sort_string = match sort {
"featured" => "featured",
"toprated" => "toprated",
_ => "new",
};
let duration_string = options
.duration
.clone()
.unwrap_or_else(|| "all".to_string());
let video_url = format!(
"{}/{}/{}?d={}",
self.url, sort_string, page, duration_string
);
// Cache Logic
if let Some((time, items)) = cache.get(&video_url) {
if time.elapsed().unwrap_or_default().as_secs() < 300 {
return Ok(items.clone());
}
}
let mut requester = options.requester.clone().ok_or("Requester missing")?;
let text = requester
.get(&video_url, None)
.await
.map_err(|e| format!("{}", e))?;
let video_items = self.get_video_items_from_html(text);
if !video_items.is_empty() {
cache.insert(video_url, video_items.clone());
}
Ok(video_items)
}
async fn query(
&self,
cache: VideoCache,
page: u8,
query: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let search_string = query.to_lowercase().trim().replace(" ", "+");
let duration_string = options
.duration
.clone()
.unwrap_or_else(|| "all".to_string());
let video_url = format!(
"{}/search?what={}&d={}&page={}",
self.url, search_string, duration_string, page
);
if let Some((time, items)) = cache.get(&video_url) {
if time.elapsed().unwrap_or_default().as_secs() < 300 {
return Ok(items.clone());
}
}
let mut requester = options.requester.clone().ok_or("Requester missing")?;
let text = requester
.get(&video_url, None)
.await
.map_err(|e| format!("{}", e))?;
let video_items = self.get_video_items_from_html(text);
if !video_items.is_empty() {
cache.insert(video_url, video_items.clone());
}
Ok(video_items)
}
fn get_video_items_from_html(&self, html: String) -> Vec<VideoItem> {
if html.is_empty() {
return vec![];
}
let mut items = Vec::new();
// Safe helper for splitting
let get_part = |input: &str, sep: &str, idx: usize| -> Option<String> {
input.split(sep).nth(idx).map(|s| s.to_string())
};
// Navigate to the video list container safely
let list_part = match html.split("row video-list").nth(1) {
Some(p) => match p.split("pagination ").next() {
Some(inner) => inner,
None => return vec![],
},
None => return vec![],
};
let raw_videos: Vec<&str> = list_part
.split("col-xs-6 col-md-4 col-xl-3 mb-3")
.skip(1)
.collect();
for (idx, segment) in raw_videos.iter().enumerate() {
let item: Option<VideoItem> = (|| {
let video_url = get_part(segment, " href=\"", 1)?
.split("\"")
.next()?
.to_string();
let mut title = get_part(segment, "class=\"video-title text-break\">", 1)?
.split("<")
.next()?
.trim()
.to_string();
title = decode(title.as_bytes()).to_string().unwrap_or(title);
let id = video_url.split("/").nth(5)?.to_string();
let thumb = if segment.contains("data-src=\"") {
get_part(segment, "data-src=\"", 1)?
.split("\"")
.next()?
.to_string()
} else {
get_part(segment, "<img src=\"", 1)?
.split("\"")
.next()?
.to_string()
};
let raw_duration = get_part(segment, "thumb-icon video-duration\">", 1)?
.split("<")
.next()?
.to_string();
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
let views = if segment.contains("icon-eye\"></i>") {
let v_str = get_part(segment, "icon-eye\"></i>", 1)?
.split("<")
.next()?
.trim()
.to_string();
parse_abbreviated_number(&v_str).unwrap_or(0) as u32
} else {
0
};
let preview = get_part(segment, "data-trailer=\"", 1)?
.split("\"")
.next()?
.to_string();
Some(
VideoItem::new(id, title, video_url, "tnaflix".to_string(), thumb, duration)
.views(views)
.preview(preview),
)
})();
if let Some(v) = item {
items.push(v);
} else {
eprintln!("Tnaflix: Failed to parse item index {}", idx);
tokio::spawn(async move {
let _ = send_discord_error_report(
format!("Tnaflix Parse Error at index {}", idx),
None,
Some("Tnaflix Provider"),
None,
file!(),
line!(),
module_path!(),
)
.await;
});
}
}
items
}
}
#[async_trait]
impl Provider for TnaflixProvider {
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_num = page.parse::<u8>().unwrap_or(1);
let result = match query {
Some(q) => self.query(cache, page_num, &q, options).await,
None => self.get(cache, page_num, &sort, options).await,
};
match result {
Ok(v) => v,
Err(e) => {
eprintln!("Tnaflix Error: {}", e);
// 1. Create a collection of owned data so we don't hold references to `e`
let mut error_reports = Vec::new();
// Iterating through the error chain to collect data into owned Strings
for cause in e.iter().skip(1) {
error_reports.push((
cause.to_string(), // Title
format_error_chain(cause), // Description/Chain
format!("caused by: {}", cause), // Message
));
}
// 2. Now that we aren't holding any `&dyn StdError`, we can safely .await
for (title, chain_str, msg) in error_reports {
let _ = send_discord_error_report(
title,
Some(chain_str),
Some("Pornzog Provider"),
Some(&msg),
file!(),
line!(),
module_path!(),
)
.await;
}
// In a real app, you'd extract owned strings here
// and await your discord reporter as we did for Pornzog
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}