Files
hottub/src/providers/missav.rs
2026-03-05 18:18:48 +00:00

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))
}
}