477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
use crate::DbPool;
|
|
use crate::api::ClientVersion;
|
|
use crate::db;
|
|
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::requester::Requester;
|
|
use crate::videos::ServerOptions;
|
|
use crate::videos::VideoItem;
|
|
use async_trait::async_trait;
|
|
use diesel::r2d2;
|
|
use error_chain::error_chain;
|
|
use futures::future::join_all;
|
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
|
use std::vec;
|
|
use wreq::Version;
|
|
|
|
error_chain! {
|
|
foreign_links {
|
|
Io(std::io::Error);
|
|
HttpRequest(wreq::Error);
|
|
JsonError(serde_json::Error);
|
|
Pool(r2d2::Error); // Assuming r2d2 or similar for pool
|
|
}
|
|
errors {
|
|
ParsingError(t: String) {
|
|
description("parsing error")
|
|
display("Parsing error: '{}'", t)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MissavProvider {
|
|
url: String,
|
|
}
|
|
|
|
impl MissavProvider {
|
|
pub fn new() -> Self {
|
|
MissavProvider {
|
|
url: "https://missav.ws".to_string(),
|
|
}
|
|
}
|
|
|
|
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
|
Channel {
|
|
id: "missav".to_string(),
|
|
name: "MissAV".to_string(),
|
|
description: "Watch HD JAV Online".to_string(),
|
|
premium: false,
|
|
favicon: "https://www.google.com/s2/favicons?sz=64&domain=missav.ws".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: "released_at".to_string(),
|
|
title: "Release Date".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "published_at".to_string(),
|
|
title: "Recent Update".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "today_views".to_string(),
|
|
title: "Today Views".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "weekly_views".to_string(),
|
|
title: "Weekly Views".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "monthly_views".to_string(),
|
|
title: "Monthly Views".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "views".to_string(),
|
|
title: "Total Views".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "filter".to_string(),
|
|
title: "Filter".to_string(),
|
|
description: "Filter the Videos".to_string(),
|
|
systemImage: "line.horizontal.3.decrease.circle".to_string(),
|
|
colorName: "green".to_string(),
|
|
options: vec![
|
|
FilterOption {
|
|
id: "new".to_string(),
|
|
title: "Recent update".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "release".to_string(),
|
|
title: "New Releases".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "uncensored-leak".to_string(),
|
|
title: "Uncensored".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "english-subtitle".to_string(),
|
|
title: "English subtitle".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
ChannelOption {
|
|
id: "language".to_string(),
|
|
title: "Language".to_string(),
|
|
description: "What Language to fetch".to_string(),
|
|
systemImage: "flag.fill".to_string(),
|
|
colorName: "gray".to_string(),
|
|
options: vec![
|
|
FilterOption {
|
|
id: "en".to_string(),
|
|
title: "English".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "cn".to_string(),
|
|
title: "简体中文".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "ja".to_string(),
|
|
title: "日本語".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "ko".to_string(),
|
|
title: "한국의".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "ms".to_string(),
|
|
title: "Melayu".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "th".to_string(),
|
|
title: "ไทย".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "de".to_string(),
|
|
title: "Deutsch".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "fr".to_string(),
|
|
title: "Français".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "vi".to_string(),
|
|
title: "Tiếng Việt".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "id".to_string(),
|
|
title: "Bahasa Indonesia".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "fil".to_string(),
|
|
title: "Filipino".to_string(),
|
|
},
|
|
FilterOption {
|
|
id: "pt".to_string(),
|
|
title: "Português".to_string(),
|
|
},
|
|
],
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
nsfw: true,
|
|
cacheDuration: None,
|
|
}
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
cache: VideoCache,
|
|
pool: DbPool,
|
|
page: u8,
|
|
mut sort: String,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
// Use ok_or to avoid unwrapping options
|
|
let language = options.language.as_ref().ok_or("Missing language")?;
|
|
let filter = options.filter.as_ref().ok_or("Missing filter")?;
|
|
let mut requester = options.requester.clone().ok_or("Missing requester")?;
|
|
|
|
if !sort.is_empty() {
|
|
sort = format!("&sort={}", sort);
|
|
}
|
|
let url_str = format!("{}/{}/{}?page={}{}", self.url, language, filter, page, sort);
|
|
|
|
if let Some((time, items)) = cache.get(&url_str) {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 3600 {
|
|
return Ok(items.clone());
|
|
}
|
|
}
|
|
|
|
let text = requester
|
|
.get(&url_str, Some(Version::HTTP_2))
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
|
let _ = send_discord_error_report(
|
|
e.to_string(),
|
|
None,
|
|
Some(&url_str),
|
|
None,
|
|
file!(),
|
|
line!(),
|
|
module_path!(),
|
|
);
|
|
"".to_string()
|
|
});
|
|
let video_items = self.get_video_items_from_html(text, pool, requester).await;
|
|
|
|
if !video_items.is_empty() {
|
|
cache.insert(url_str, video_items.clone());
|
|
}
|
|
Ok(video_items)
|
|
}
|
|
|
|
async fn query(
|
|
&self,
|
|
cache: VideoCache,
|
|
pool: DbPool,
|
|
page: u8,
|
|
query: &str,
|
|
mut sort: String,
|
|
options: ServerOptions,
|
|
) -> Result<Vec<VideoItem>> {
|
|
let language = options.language.as_ref().ok_or("Missing language")?;
|
|
let mut requester = options.requester.clone().ok_or("Missing requester")?;
|
|
|
|
let search_string = query.replace(" ", "%20");
|
|
if !sort.is_empty() {
|
|
sort = format!("&sort={}", sort);
|
|
}
|
|
let url_str = format!(
|
|
"{}/{}/search/{}?page={}{}",
|
|
self.url, language, search_string, page, sort
|
|
);
|
|
|
|
if let Some((time, items)) = cache.get(&url_str) {
|
|
if time.elapsed().unwrap_or_default().as_secs() < 3600 {
|
|
return Ok(items.clone());
|
|
}
|
|
}
|
|
|
|
let text = requester
|
|
.get(&url_str, Some(Version::HTTP_2))
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
|
let _ = send_discord_error_report(
|
|
e.to_string(),
|
|
None,
|
|
Some(&url_str),
|
|
None,
|
|
file!(),
|
|
line!(),
|
|
module_path!(),
|
|
);
|
|
"".to_string()
|
|
});
|
|
let video_items = self.get_video_items_from_html(text, pool, requester).await;
|
|
|
|
if !video_items.is_empty() {
|
|
cache.insert(url_str, video_items.clone());
|
|
}
|
|
Ok(video_items)
|
|
}
|
|
|
|
async fn get_video_items_from_html(
|
|
&self,
|
|
html: String,
|
|
pool: DbPool,
|
|
requester: Requester,
|
|
) -> Vec<VideoItem> {
|
|
if html.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let segments: Vec<&str> = html.split("@mouseenter=\"setPreview(\'").collect();
|
|
if segments.len() < 2 {
|
|
return vec![];
|
|
}
|
|
|
|
let mut urls = vec![];
|
|
for video_segment in &segments[1..] {
|
|
// Safer parsing: find start and end of href
|
|
if let Some(start) = video_segment.find("<a href=\"") {
|
|
let rest = &video_segment[start + 9..];
|
|
if let Some(end) = rest.find('\"') {
|
|
urls.push(rest[..end].to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
let futures = urls
|
|
.into_iter()
|
|
.map(|url| self.get_video_item(url, pool.clone(), requester.clone()));
|
|
join_all(futures)
|
|
.await
|
|
.into_iter()
|
|
.filter_map(Result::ok)
|
|
.collect()
|
|
}
|
|
|
|
async fn get_video_item(
|
|
&self,
|
|
url_str: String,
|
|
pool: DbPool,
|
|
mut requester: Requester,
|
|
) -> Result<VideoItem> {
|
|
// 1. Database Check
|
|
{
|
|
let mut conn = pool
|
|
.get()
|
|
.map_err(|e| Error::from(format!("Pool error: {}", e)))?;
|
|
if let Ok(Some(entry)) = db::get_video(&mut conn, url_str.clone()) {
|
|
if let Ok(video_item) = serde_json::from_str::<VideoItem>(entry.as_str()) {
|
|
return Ok(video_item);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Fetch Page
|
|
let vid = requester
|
|
.get(&url_str, Some(Version::HTTP_2))
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("Error fetching Missav URL {}: {}", url_str, e);
|
|
let _ = send_discord_error_report(
|
|
e.to_string(),
|
|
None,
|
|
Some(&url_str),
|
|
None,
|
|
file!(),
|
|
line!(),
|
|
module_path!(),
|
|
);
|
|
"".to_string()
|
|
});
|
|
|
|
// Helper closure to extract content between two strings
|
|
let extract = |html: &str, start_tag: &str, end_tag: &str| -> Option<String> {
|
|
let start = html.find(start_tag)? + start_tag.len();
|
|
let rest = &html[start..];
|
|
let end = rest.find(end_tag)?;
|
|
Some(rest[..end].to_string())
|
|
};
|
|
|
|
let mut title = extract(&vid, "<meta property=\"og:title\" content=\"", "\"")
|
|
.ok_or_else(|| ErrorKind::ParsingError(format!("title\n{:?}", vid)))?;
|
|
|
|
title = decode(title.as_bytes()).to_string().unwrap_or(title);
|
|
if url_str.contains("uncensored") {
|
|
title = format!("[Uncensored] {}", title);
|
|
}
|
|
|
|
let thumb =
|
|
extract(&vid, "<meta property=\"og:image\" content=\"", "\"").unwrap_or_default();
|
|
|
|
let duration = extract(
|
|
&vid,
|
|
"<meta property=\"og:video:duration\" content=\"",
|
|
"\"",
|
|
)
|
|
.and_then(|d| d.parse::<u32>().ok())
|
|
.unwrap_or(0);
|
|
|
|
let id = url_str.split('/').last().ok_or("No ID found")?.to_string();
|
|
|
|
// 3. Extract Tags (Generic approach to avoid repetitive code)
|
|
let mut tags = vec![];
|
|
for (label, prefix) in [
|
|
("Actress:", "@actress"),
|
|
("Actor:", "@actor"),
|
|
("Maker:", "@maker"),
|
|
("Genre:", "@genre"),
|
|
] {
|
|
let marker = format!("<span>{}</span>", label);
|
|
if let Some(section) = extract(&vid, &marker, "</div>") {
|
|
for part in section.split("class=\"text-nord13 font-medium\">").skip(1) {
|
|
if let Some(val) = part.split('<').next() {
|
|
let clean = val.trim();
|
|
if !clean.is_empty() {
|
|
tags.push(format!("{}:{}", prefix, clean));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Extract Video URL (The m3u8 logic)
|
|
let video_url = (|| {
|
|
let parts_str = vid.split("m3u8").nth(1)?.split("https").next()?;
|
|
let mut parts: Vec<&str> = parts_str.split('|').collect();
|
|
parts.reverse();
|
|
Some(format!(
|
|
"https://{}.{}/{}-{}-{}-{}-{}/playlist.m3u8",
|
|
parts.get(1)?,
|
|
parts.get(2)?,
|
|
parts.get(3)?,
|
|
parts.get(4)?,
|
|
parts.get(5)?,
|
|
parts.get(6)?,
|
|
parts.get(7)?
|
|
))
|
|
})()
|
|
.ok_or_else(|| ErrorKind::ParsingError(format!("video_url\n{:?}", vid).to_string()))?;
|
|
|
|
let video_item =
|
|
VideoItem::new(id, title, video_url, "missav".to_string(), thumb, duration)
|
|
.tags(tags)
|
|
.preview(format!(
|
|
"https://fourhoi.com/{}/preview.mp4",
|
|
url_str.split('/').last().unwrap_or_default()
|
|
));
|
|
|
|
// 5. Cache to DB
|
|
if let Ok(mut conn) = pool.get() {
|
|
let _ = db::insert_video(
|
|
&mut conn,
|
|
&url_str,
|
|
&serde_json::to_string(&video_item).unwrap_or_default(),
|
|
);
|
|
}
|
|
|
|
Ok(video_item)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for MissavProvider {
|
|
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, pool, page_num, &q, sort, options).await,
|
|
None => self.get(cache, pool, page_num, sort, options).await,
|
|
};
|
|
|
|
result.unwrap_or_else(|e| {
|
|
eprintln!("Error fetching videos: {}", e);
|
|
let _ = send_discord_error_report(
|
|
e.to_string(),
|
|
Some(format_error_chain(&e)),
|
|
None,
|
|
None,
|
|
file!(),
|
|
line!(),
|
|
module_path!(),
|
|
);
|
|
vec![]
|
|
})
|
|
}
|
|
|
|
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
|
Some(self.build_channel(clientversion))
|
|
}
|
|
}
|