Files
hottub/src/providers/pmvhaven.rs
2025-11-29 18:52:48 +00:00

276 lines
10 KiB
Rust

use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::Provider;
use crate::status::*;
use crate::util::cache::VideoCache;
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::sync::{Arc, RwLock};
use std::{vec};
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
}
#[derive(Debug, Clone)]
pub struct PmvhavenProvider {
url: String,
stars: Arc<RwLock<Vec<String>>>,
categories: Arc<RwLock<Vec<String>>>,
}
impl PmvhavenProvider {
pub fn new() -> Self {
let provider = PmvhavenProvider {
url: "https://pmvhaven.com".to_string(),
stars: Arc::new(RwLock::new(vec![])),
categories: Arc::new(RwLock::new(vec![])),
};
provider
}
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
// if clientversion >= ClientVersion::new(22, 101, "22e".to_string()) {
let _ = clientversion;
Channel {
id: "pmvhaven".to_string(),
name: "PMVHaven".to_string(),
description: "Best PMV Videos".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=pmvhaven.com".to_string(),
status: "active".to_string(),
categories: self.categories.read().unwrap().iter().map(|c| c.clone()).collect(),
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: "relevance".into(),
title: "Relevance".into(),
},
FilterOption {
id: "newest".into(),
title: "Newest".into(),
},
FilterOption {
id: "oldest".into(),
title: "Oldest".into(),
},
FilterOption {
id: "most viewed".into(),
title: "Most Viewed".into(),
},
FilterOption {
id: "most liked".into(),
title: "Most Liked".into(),
},
FilterOption {
id: "most disliked".into(),
title: "Most Disliked".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: "any".into(),
title: "Any".into(),
},
FilterOption {
id: "<4 min".into(),
title: "<4 min".into(),
},
FilterOption {
id: "4-20 min".into(),
title: "4-20 min".into(),
},
FilterOption {
id: "20-60 min".into(),
title: "20-60 min".into(),
},
FilterOption {
id: ">1 hour".into(),
title: ">1 hour".into(),
},
],
multiSelect: false,
},
],
nsfw: true,
cacheDuration: None,
}
}
// Push one item with minimal lock time and dedup by id
fn push_unique(target: &Arc<RwLock<Vec<String>>>, item: String) {
if let Ok(mut vec) = target.write() {
if !vec.iter().any(|x| x == &item) {
vec.push(item);
}
}
}
async fn query(
&self,
cache: VideoCache,
page: u8,
query: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let search_string = query.trim().to_string();
let sort_string = match options.sort.unwrap_or("".to_string()).as_str() {
"newest" => "sort=-uploadDate",
"oldest" => "sort=uploadDate",
"most viewed" => "sort=-views",
"most liked" => "sort=-likes",
"most disliked" => "sort=-dislikes",
_ => "",
};
let duration_string = match options.duration.unwrap_or("".to_string()).as_str(){
"<4 min" => "durationMax=240",
"4-20 min" => "durationMin=240&durationMax=1200",
"20-60 min" => "durationMin=1200&durationMax=3600",
">1 hour" => "durationMin=3600",
_ => "",
};
let endpoint = if search_string.is_empty() {
"api/videos"
} else {
"api/videos/search"
};
let mut video_url = format!("{}/{}?limit=100&page={}&{}&{}", self.url, endpoint, page, duration_string, sort_string);
if let Some(star) = self.stars.read().unwrap().iter().find(|s| s.to_ascii_lowercase() == search_string.to_ascii_lowercase()) {
video_url = format!("{}&stars={}", video_url, star);
} else if let Some(category) = self.categories.read().unwrap().iter().find(|s| s.to_ascii_lowercase() == search_string.to_ascii_lowercase()) {
video_url = format!("{}&tagMode=AND&tags={}", video_url, category);
}
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());
} else {
let _ = cache.check().await;
return Ok(items.clone());
}
}
None => {
vec![]
}
};
let mut requester = options.requester.clone().unwrap();
let text = requester.get(&video_url).await.unwrap();
let json = serde_json::from_str::<serde_json::Value>(&text).unwrap_or(serde_json::Value::Null);
let video_items: Vec<VideoItem> = self
.get_video_items_from_json(json)
.await;
if !video_items.is_empty() {
cache.remove(&video_url);
cache.insert(video_url.clone(), video_items.clone());
} else {
return Ok(old_items);
}
Ok(video_items)
}
async fn get_video_items_from_json(
&self,
json: serde_json::Value,
) -> Vec<VideoItem> {
if json.is_null() {
return vec![];
}
let mut items = vec![];
let success = json["success"].as_bool().unwrap_or(false);
if !success {
return items;
}
let videos = json["data"].as_array().cloned().unwrap_or_default();
if videos.is_empty() {
return items;
}
for video in videos.clone() {
let title = decode(video["title"].as_str().unwrap_or("").as_bytes()).to_string().unwrap_or("".to_string());
let id = video["_id"].as_str().unwrap_or(title.clone().as_str()).to_string();
let video_url = video["videoUrl"].as_str().unwrap_or("").to_string();
let views = video["views"].as_u64().unwrap_or(0);
let thumb = video["thumbnailUrl"].as_str().unwrap_or("").to_string();
let duration_str = video["duration"].as_str().unwrap_or("0");
let duration = parse_time_to_seconds(duration_str).unwrap_or(0);
let preview = video["previewUrl"].as_str().unwrap_or("").to_string();
let tags_array = video["tags"].as_array().cloned().unwrap_or_default();
for tag in tags_array.clone() {
let tag_str = decode(tag.as_str().unwrap_or("").as_bytes()).to_string().unwrap_or("".to_string());
Self::push_unique(&self.categories, tag_str.clone());
}
let stars_array = video["starsTags"].as_array().cloned().unwrap_or_default();
for tag in stars_array.clone() {
let tag_str = decode(tag.as_str().unwrap_or("").as_bytes()).to_string().unwrap_or("".to_string());
Self::push_unique(&self.stars, tag_str.clone());
}
let tags = stars_array.iter().chain(tags_array.iter()).cloned().collect::<Vec<_>>();
let video_item = VideoItem::new(
id,
title,
video_url.replace(" ", "%20").to_string(),
"pmvhaven".to_string(),
thumb,
duration as u32,
)
.views(views as u32)
.preview(preview)
.tags(tags.iter().map(|t| decode(t.as_str().unwrap_or("").as_bytes()).to_string().unwrap_or("".to_string())).collect());
items.push(video_item);
}
return items;
}
}
#[async_trait]
impl Provider for PmvhavenProvider {
async fn get_videos(
&self,
cache: VideoCache,
pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = per_page;
let _ = pool;
let videos: std::result::Result<Vec<VideoItem>, Error> = self.query(cache, page.parse::<u8>().unwrap_or(1), query.unwrap_or("".to_string()).as_str(), options)
.await;
match videos {
Ok(v) => v,
Err(e) => {
println!("Error fetching videos: {}", e);
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> crate::status::Channel {
self.build_channel(clientversion)
}
}