482 lines
16 KiB
Rust
482 lines
16 KiB
Rust
use crate::DbPool;
|
|
use crate::api::ClientVersion;
|
|
use crate::providers::{Provider, report_provider_error_background, requester_or_default};
|
|
use crate::status::*;
|
|
use crate::util::cache::VideoCache;
|
|
use crate::util::discord::send_discord_error_report;
|
|
use crate::util::time::parse_time_to_seconds;
|
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
|
use async_trait::async_trait;
|
|
use error_chain::error_chain;
|
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
|
use std::fmt::Write;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::vec;
|
|
use url::form_urlencoded::Serializer;
|
|
|
|
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 {
|
|
Self {
|
|
url: "https://pmvhaven.com".to_string(),
|
|
stars: Arc::new(RwLock::new(vec![])),
|
|
categories: Arc::new(RwLock::new(vec![])),
|
|
}
|
|
}
|
|
|
|
fn encode_query_value(value: &str) -> String {
|
|
let mut serializer = Serializer::new(String::new());
|
|
serializer.append_pair("v", value);
|
|
let encoded = serializer.finish();
|
|
encoded.strip_prefix("v=").unwrap_or(&encoded).to_string()
|
|
}
|
|
|
|
fn build_channel(&self, clientversion: ClientVersion) -> Channel {
|
|
let _ = clientversion;
|
|
|
|
let categories = self
|
|
.categories
|
|
.read()
|
|
.map(|g| g.clone())
|
|
.unwrap_or_default();
|
|
|
|
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,
|
|
options: vec![
|
|
ChannelOption {
|
|
id: "sort".into(),
|
|
title: "Sort".into(),
|
|
description: "Sort the Videos".into(),
|
|
systemImage: "list.number".into(),
|
|
colorName: "blue".into(),
|
|
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".into(),
|
|
title: "Duration".into(),
|
|
description: "Length of the Videos".into(),
|
|
systemImage: "timer".into(),
|
|
colorName: "green".into(),
|
|
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,
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_direct_media_url(url: &str) -> bool {
|
|
let lower = url.to_ascii_lowercase();
|
|
(lower.starts_with("http://") || lower.starts_with("https://"))
|
|
&& (lower.contains("/videos/") || lower.contains(".mp4") || lower.contains(".m3u8"))
|
|
}
|
|
|
|
fn pick_downloadable_media_url(&self, video: &serde_json::Value) -> Option<String> {
|
|
let video_url = video
|
|
.get("videoUrl")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim();
|
|
if Self::is_direct_media_url(video_url) {
|
|
return Some(video_url.replace(' ', "%20"));
|
|
}
|
|
|
|
// Fallback: derive direct media URL from object key.
|
|
let key = video
|
|
.get("key")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.trim_matches('/');
|
|
if !key.is_empty() {
|
|
let rebuilt = format!("https://video.pmvhaven.com/{key}");
|
|
if Self::is_direct_media_url(&rebuilt) {
|
|
return Some(rebuilt.replace(' ', "%20"));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
async fn query(
|
|
&self,
|
|
cache: VideoCache,
|
|
page: u8,
|
|
query: &str,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let search = query.trim().to_string();
|
|
|
|
let sort = match options.sort.as_deref() {
|
|
Some("newest") => "&sort=-uploadDate",
|
|
Some("oldest") => "&sort=uploadDate",
|
|
Some("most viewed") => "&sort=-views",
|
|
Some("most liked") => "&sort=-likes",
|
|
Some("most disliked") => "&sort=-dislikes",
|
|
_ => "",
|
|
};
|
|
|
|
let duration = match options.duration.as_deref() {
|
|
Some("<4 min") => "&durationMax=240",
|
|
Some("4-20 min") => "&durationMin=240&durationMax=1200",
|
|
Some("20-60 min") => "&durationMin=1200&durationMax=3600",
|
|
Some(">1 hour") => "&durationMin=3600",
|
|
_ => "",
|
|
};
|
|
|
|
let encoded_search = Self::encode_query_value(&search);
|
|
|
|
let mut extra_filters = String::new();
|
|
if let Ok(stars) = self.stars.read() {
|
|
if let Some(star) = stars.iter().find(|s| s.eq_ignore_ascii_case(&search)) {
|
|
let encoded_star = Self::encode_query_value(star);
|
|
extra_filters.push_str(&format!("&stars={encoded_star}"));
|
|
}
|
|
}
|
|
|
|
if let Ok(cats) = self.categories.read() {
|
|
if let Some(cat) = cats.iter().find(|c| c.eq_ignore_ascii_case(&search)) {
|
|
let encoded_cat = Self::encode_query_value(cat);
|
|
extra_filters.push_str(&format!("&tagMode=OR&tags={encoded_cat}&expandTags=false"));
|
|
}
|
|
}
|
|
|
|
let mut urls = vec![];
|
|
if search.is_empty() {
|
|
urls.push(format!(
|
|
"{}/api/videos?limit=100&page={page}{duration}{sort}{extra_filters}",
|
|
self.url
|
|
));
|
|
} else {
|
|
urls.push(format!(
|
|
"{}/api/videos/search?limit=100&page={page}{duration}{sort}{extra_filters}&q={encoded_search}",
|
|
self.url
|
|
));
|
|
urls.push(format!(
|
|
"{}/api/videos/search?limit=100&page={page}{duration}{sort}{extra_filters}&query={encoded_search}",
|
|
self.url
|
|
));
|
|
}
|
|
|
|
let mut requester = requester_or_default(&options, "pmvhaven", "query");
|
|
for url in urls {
|
|
if let Some((time, items)) = cache.get(&url) {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 300 {
|
|
return Ok(items.clone());
|
|
}
|
|
}
|
|
|
|
let text = match requester.get(&url, None).await {
|
|
Ok(text) => text,
|
|
Err(err) => {
|
|
report_provider_error_background(
|
|
"pmvhaven",
|
|
"get.request",
|
|
&format!("url={url}; error={err}"),
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let json: serde_json::Value = match serde_json::from_str(&text) {
|
|
Ok(json) => json,
|
|
Err(err) => {
|
|
report_provider_error_background(
|
|
"pmvhaven",
|
|
"parse.json",
|
|
&format!("url={url}; error={err}"),
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let items = self.get_video_items_from_json(json).await;
|
|
|
|
if !items.is_empty() {
|
|
cache.remove(&url);
|
|
cache.insert(url, items.clone());
|
|
return Ok(items);
|
|
}
|
|
}
|
|
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn get_video_items_from_json(&self, json: serde_json::Value) -> Vec<VideoItem> {
|
|
let mut items = vec![];
|
|
|
|
if !json
|
|
.get("success")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
{
|
|
return items;
|
|
}
|
|
|
|
let videos = json
|
|
.get("data")
|
|
.and_then(|v| v.as_array())
|
|
.or_else(|| json.get("videos").and_then(|v| v.as_array()))
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
for video in videos {
|
|
let title = decode(
|
|
video
|
|
.get("title")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.as_bytes(),
|
|
)
|
|
.to_string()
|
|
.unwrap_or_default();
|
|
|
|
let id = video
|
|
.get("_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&title)
|
|
.to_string();
|
|
|
|
let video_url = match self.pick_downloadable_media_url(&video) {
|
|
Some(url) => url,
|
|
None => {
|
|
continue;
|
|
}
|
|
};
|
|
let thumb = video
|
|
.get("thumbnailUrl")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let preview = video
|
|
.get("previewUrl")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
let views = video.get("views").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
let duration = parse_time_to_seconds(
|
|
video
|
|
.get("duration")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("0"),
|
|
)
|
|
.unwrap_or(0);
|
|
|
|
let tags = video
|
|
.get("tags")
|
|
.and_then(|v| v.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let stars = video
|
|
.get("starsTags")
|
|
.and_then(|v| v.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
for t in tags.iter() {
|
|
if let Some(s) = t.as_str() {
|
|
let decoded = decode(s.as_bytes()).to_string().unwrap_or_default();
|
|
Self::push_unique(&self.categories, decoded.clone());
|
|
}
|
|
}
|
|
for t in stars.iter() {
|
|
if let Some(s) = t.as_str() {
|
|
let decoded = decode(s.as_bytes()).to_string().unwrap_or_default();
|
|
Self::push_unique(&self.stars, decoded.clone());
|
|
}
|
|
}
|
|
|
|
let format_type = if video_url.to_ascii_lowercase().contains(".m3u8") {
|
|
"m3u8".to_string()
|
|
} else {
|
|
"mp4".to_string()
|
|
};
|
|
items.push(
|
|
VideoItem::new(
|
|
id,
|
|
title,
|
|
video_url.clone(),
|
|
"pmvhaven".into(),
|
|
thumb,
|
|
duration as u32,
|
|
)
|
|
.views(views as u32)
|
|
.formats(vec![VideoFormat::new(
|
|
video_url,
|
|
"1080".to_string(),
|
|
format_type,
|
|
)])
|
|
.preview(preview),
|
|
);
|
|
}
|
|
|
|
items
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::PmvhavenProvider;
|
|
use serde_json::json;
|
|
|
|
#[tokio::test]
|
|
async fn parses_videos_from_videos_key() {
|
|
let provider = PmvhavenProvider::new();
|
|
let payload = json!({
|
|
"success": true,
|
|
"videos": [{
|
|
"_id": "abc123",
|
|
"title": "Sample Title",
|
|
"videoUrl": "https://video.pmvhaven.com/videos/sample.mp4",
|
|
"thumbnailUrl": "https://video.pmvhaven.com/thumbnails/sample.webp",
|
|
"previewUrl": "https://video.pmvhaven.com/previews/sample.mp4",
|
|
"views": 42,
|
|
"duration": "2:11",
|
|
"tags": [],
|
|
"starsTags": []
|
|
}]
|
|
});
|
|
|
|
let items = provider.get_video_items_from_json(payload).await;
|
|
assert_eq!(items.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parses_videos_from_data_key() {
|
|
let provider = PmvhavenProvider::new();
|
|
let payload = json!({
|
|
"success": true,
|
|
"data": [{
|
|
"_id": "abc123",
|
|
"title": "Sample Title",
|
|
"videoUrl": "https://video.pmvhaven.com/videos/sample.mp4",
|
|
"thumbnailUrl": "https://video.pmvhaven.com/thumbnails/sample.webp",
|
|
"previewUrl": "https://video.pmvhaven.com/previews/sample.mp4",
|
|
"views": 42,
|
|
"duration": "2:11",
|
|
"tags": [],
|
|
"starsTags": []
|
|
}]
|
|
});
|
|
|
|
let items = provider.get_video_items_from_json(payload).await;
|
|
assert_eq!(items.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[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 page = page.parse::<u8>().unwrap_or(1);
|
|
let query = query.unwrap_or_default();
|
|
|
|
match self.query(cache, page, &query, options).await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("pmvhaven error: {e}");
|
|
let mut chain_str = String::new();
|
|
for (i, cause) in e.iter().enumerate() {
|
|
let _ = writeln!(chain_str, "{}. {}", i + 1, cause);
|
|
}
|
|
send_discord_error_report(
|
|
e.to_string(),
|
|
Some(chain_str),
|
|
Some("PMVHaven Provider"),
|
|
Some("Failed to load videos from PMVHaven"),
|
|
file!(),
|
|
line!(),
|
|
module_path!(),
|
|
)
|
|
.await;
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
|
Some(self.build_channel(clientversion))
|
|
}
|
|
}
|