353 lines
12 KiB
Rust
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))
|
|
}
|
|
}
|