From 94cc3d7bbb3b3910e7436637a430ede2a746693d Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 17:29:04 +0000 Subject: [PATCH] melonstube --- build.rs | 5 + docs/provider-catalog.md | 1 + src/providers/melonstube.rs | 621 ++++++++++++++++++++++++++++++++++++ src/providers/redgifs.rs | 2 +- src/proxies/melonstube.rs | 251 +++++++++++++++ src/proxies/mod.rs | 1 + src/proxy.rs | 5 + 7 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 src/providers/melonstube.rs create mode 100644 src/proxies/melonstube.rs diff --git a/build.rs b/build.rs index 54a262b..371d842 100644 --- a/build.rs +++ b/build.rs @@ -321,6 +321,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "allpornstream", ty: "AllPornStreamProvider", }, + ProviderDef { + id: "melonstube", + module: "melonstube", + ty: "MelonstubeProvider", + }, ProviderDef { id: "tube8", module: "tube8", diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index 1c3e016..4b34789 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -29,6 +29,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us | `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. | | `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. | | `javtiful` | `jav` | no | no | JAV channel family. | +| `melonstube` | `mainstream-tube` | no | yes | Meta-search aggregator for melonstube.com — every card is an `/out/?l=&c=&v=3` redirect link whose destination (a third-party host) is embedded, zero-network, in a MessagePack blob under the `l` param (mixed binary/string fields: view/click counts, a JSON date-range blob, an array of related-video ids, and the destination URL itself as one msgpack string). Decoding is local: base64-decode `l`, then regex-extract the `https?://...` destination directly out of the raw (lossy-UTF8-converted) msgpack bytes — **the regex must be a positive allowlist of legal URI characters** (`[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+`), not a denylist of a few excluded characters: the byte immediately following the msgpack string (a length-prefix marker for the next field, e.g. `\xcd`) lossy-converts to U+FFFD (code point 0xFFFD), which a denylist limited to ASCII control chars (`[^\x00-\x1f\\"']`) fails to exclude, silently appending a corrupt trailing character to the URL. Feeds: `/new` (latest), `/popular`, `/rating` (all three are static curated lists, ~120 items, true pagination via `?page=N`); search via `/search?q=`. Destination hosts fall into three buckets: (1) the common case — yt-dlp (`--impersonate chrome-120`) resolves the destination directly, so `video.url` is just the decoded destination URL, no proxy; (2) `JUNK_HOSTS` (currently `fhgte.com`) — observed dead-end paywall/signup funnels with no free playable video, so cards pointing there are dropped entirely rather than surfaced as false-positive results; (3) `HARD_HOSTS` (`manysex.com`, `videomanysex.com`) — yt-dlp cannot resolve these, so `video.url` is routed through `/proxy/melonstube/{host}/{path}`. The proxy (`src/proxies/melonstube.rs`) ports vjav.rs's Cyrillic-homoglyph-obfuscated base64 decode chain to pull the real `get_file` path/query out of `videofile.php`'s `video_url` field, then makes two manual (non-auto-redirect) hops to the final CDN URL: hop 1 needs the manysex.com/videomanysex.com page as `Referer`; hop 2 (to the signed `ahcdn.com` URL) must be sent with **no** Referer at all, because the CDN's signed URL embeds a literal `referer=none,.manysex.com,.gstatic.com` allow-list that rejects the videomanysex.com Referer used on hop 1 — auto-redirect clients that forward the same Referer to every hop get a 403 at hop 2. Before returning the 302, an anti-false-positive check confirms the resolved URL string contains the requested numeric video id and that a ranged GET (`Range: bytes=0-65535`) returns 200/206 with a `video/*`/`octet-stream` content-type, so a paywall/ad/error page can't masquerade as the real stream. Thumbnails (`ttcache.com`) load directly, no proxy. No `/api/uploaders` (aggregator has no stable uploader identity). | | `missav` | `jav` | no | no | HLS format pattern. | | `noodlemagazine` | `mainstream-tube` | no | yes | Best template for media and thumbnail proxying. | | `okporn` | `mainstream-tube` | no | no | Simple mainstream archive. | diff --git a/src/providers/melonstube.rs b/src/providers/melonstube.rs new file mode 100644 index 0000000..121f95d --- /dev/null +++ b/src/providers/melonstube.rs @@ -0,0 +1,621 @@ +use crate::DbPool; +use crate::api::ClientVersion; +use crate::providers::{ + Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme, +}; +use crate::status::*; +use crate::util::cache::VideoCache; +use crate::videos::{ServerOptions, VideoItem}; +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chrono::{Duration as ChronoDuration, Utc}; +use error_chain::error_chain; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use regex::Regex; +use scraper::{Html, Selector}; + +pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = + crate::providers::ProviderChannelMetadata { + group_id: "mainstream-tube", + tags: &["tube", "aggregator", "mixed"], + }; + +const BASE_URL: &str = "https://www.melonstube.com"; +const CHANNEL_ID: &str = "melonstube"; +const BROWSER_UA: &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +// Destination hosts we have a dedicated resolver proxy for (yt-dlp cannot +// solve these directly). +const HARD_HOSTS: &[&str] = &["manysex.com", "videomanysex.com"]; + +// Destination hosts observed to be dead-end funnels (paywall/signup, no +// free playable video) rather than genuine hard-to-resolve hosts. Cards +// pointing at these are dropped entirely so they don't surface as +// false-positive "ad" results. +const JUNK_HOSTS: &[&str] = &["fhgte.com"]; + +error_chain! { + foreign_links { + Io(std::io::Error); + HttpRequest(wreq::Error); + } +} + +#[derive(Debug, Clone)] +enum Target { + Latest { page: u32 }, + Popular { page: u32 }, + Rating { page: u32 }, + Search { query: String, page: u32 }, +} + +#[derive(Debug, Clone)] +pub struct MelonstubeProvider {} + +impl MelonstubeProvider { + pub fn new() -> Self { + Self {} + } + + fn build_channel(&self, _clientversion: ClientVersion) -> Channel { + Channel { + id: CHANNEL_ID.to_string(), + name: "Melons Tube".to_string(), + description: "Meta-search aggregator linking out to videos hosted across many other sites.".to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=melonstube.com".to_string(), + status: "active".to_string(), + categories: vec![], + options: vec![ChannelOption { + id: "sort".to_string(), + title: "Sort".to_string(), + description: "Browse the latest, most popular, or top rated feed.".to_string(), + systemImage: "list.number".to_string(), + colorName: "blue".to_string(), + options: vec![ + FilterOption { + id: "new".to_string(), + title: "Latest".to_string(), + }, + FilterOption { + id: "popular".to_string(), + title: "Popular".to_string(), + }, + FilterOption { + id: "rating".to_string(), + title: "Top Rated".to_string(), + }, + ], + multiSelect: false, + }], + nsfw: true, + cacheDuration: Some(1800), + ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()), + } + } + + fn build_url(target: &Target) -> String { + match target { + Target::Latest { page } => { + if *page > 1 { + format!("{BASE_URL}/new?page={page}") + } else { + format!("{BASE_URL}/new") + } + } + Target::Popular { page } => { + if *page > 1 { + format!("{BASE_URL}/popular?page={page}") + } else { + format!("{BASE_URL}/popular") + } + } + Target::Rating { page } => { + if *page > 1 { + format!("{BASE_URL}/rating?page={page}") + } else { + format!("{BASE_URL}/rating") + } + } + Target::Search { query, page } => { + let encoded = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string(); + if *page > 1 { + format!("{BASE_URL}/search?q={encoded}&page={page}") + } else { + format!("{BASE_URL}/search?q={encoded}") + } + } + } + } + + fn parse_duration(text: &str) -> u32 { + let parts: Vec = text + .trim() + .split(':') + .filter_map(|p| p.parse::().ok()) + .collect(); + match parts.as_slice() { + [m, s] => m * 60 + s, + [h, m, s] => h * 3600 + m * 60 + s, + _ => 0, + } + } + + fn parse_relative_ago(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + let regex = Regex::new( + r"(?i)^(?P\d+)\s+(?Psecond|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)\s+ago$", + ) + .ok()?; + let captures = regex.captures(trimmed)?; + let amount = captures.name("amount")?.as_str().parse::().ok()?; + let unit = captures.name("unit")?.as_str().to_ascii_lowercase(); + let now = Utc::now(); + let timestamp = match unit.as_str() { + "second" | "seconds" => now - ChronoDuration::seconds(amount), + "minute" | "minutes" => now - ChronoDuration::minutes(amount), + "hour" | "hours" => now - ChronoDuration::hours(amount), + "day" | "days" => now - ChronoDuration::days(amount), + "week" | "weeks" => now - ChronoDuration::weeks(amount), + "month" | "months" => now - ChronoDuration::days(amount * 30), + "year" | "years" => now - ChronoDuration::days(amount * 365), + _ => return None, + }; + Some(timestamp.timestamp() as u64) + } + + fn clean_title(title: &str) -> String { + title + .trim() + .trim_matches(|c: char| { + matches!(c, '"' | '\u{201c}' | '\u{201d}' | '\u{2018}' | '\u{2019}') + }) + .trim() + .to_string() + } + + /// Decodes a melonstube `/out/?l=&c=&v=3&` out-link entirely + /// locally (no network calls): percent-decoding is handled by `url::Url`'s + /// query-pair parser, then the `l` payload is base64-decoded (it is standard + /// MessagePack carrying the embedded destination URL as a plain string), and + /// the destination URL is recovered with a small regex scan over the decoded + /// bytes. Returns `(destination_url, stable_id)`. + fn decode_out_link(href: &str) -> Option<(String, String)> { + let full = if href.starts_with("http") { + href.to_string() + } else { + format!("{BASE_URL}{href}") + }; + let parsed = url::Url::parse(&full).ok()?; + if parsed.path() != "/out/" { + return None; + } + + let mut l_val = None; + let mut c_val = None; + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "l" => l_val = Some(value.into_owned()), + "c" => c_val = Some(value.into_owned()), + _ => {} + } + } + let l_val = l_val?; + let id = c_val.unwrap_or_default(); + + let mut normalized = l_val.trim().to_string(); + while normalized.len() % 4 != 0 { + normalized.push('='); + } + let decoded_bytes = STANDARD.decode(normalized).ok()?; + let text = String::from_utf8_lossy(&decoded_bytes); + // The destination URL is one msgpack string embedded among binary + // fields; a byte class of only legal URI characters keeps the match + // from bleeding into the msgpack length-prefix byte that immediately + // follows the string (which lossy-converts to U+FFFD). + let regex = Regex::new(r#"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+"#).ok()?; + let destination = regex.find(&text)?.as_str().to_string(); + + Some((destination, id)) + } + + fn is_junk_host(host: &str) -> bool { + JUNK_HOSTS.contains(&host) || host.starts_with("join.") + } + + fn is_hard_host(host: &str) -> bool { + HARD_HOSTS.contains(&host) + } + + fn parse_listing(&self, html: &str, options: &ServerOptions) -> Vec { + let document = Html::parse_document(html); + + let card_sel = match Selector::parse("div.card[data-public-id]") { + Ok(s) => s, + Err(_) => return vec![], + }; + let link_sel = match Selector::parse("a.item-link[href]") { + Ok(s) => s, + Err(_) => return vec![], + }; + let img_sel = match Selector::parse("img.item-image") { + Ok(s) => s, + Err(_) => return vec![], + }; + let model_sel = match Selector::parse("a.pornstar-label") { + Ok(s) => s, + Err(_) => return vec![], + }; + let source_sel = match Selector::parse(r#"a.item-source[href^="/source/"]"#) { + Ok(s) => s, + Err(_) => return vec![], + }; + + let dur_re = match Regex::new(r#"class="badge[^"]*"[^>]*>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*"#) { + Ok(r) => r, + Err(_) => return vec![], + }; + let ago_re = match Regex::new(r#"([^<]+)"#) { + Ok(r) => r, + Err(_) => return vec![], + }; + + let mut items = Vec::new(); + + for card in document.select(&card_sel) { + let public_id = match card.value().attr("data-public-id") { + Some(v) if !v.is_empty() => v.to_string(), + _ => continue, + }; + + let Some(link) = card.select(&link_sel).next() else { + continue; + }; + let href = link.value().attr("href").unwrap_or_default(); + let raw_title = link.value().attr("title").unwrap_or_default(); + + let Some((destination_url, _hash_id)) = Self::decode_out_link(href) else { + continue; + }; + let Ok(dest_parsed) = url::Url::parse(&destination_url) else { + continue; + }; + let Some(host) = dest_parsed.host_str() else { + continue; + }; + if Self::is_junk_host(host) { + continue; + } + + let title = Self::clean_title(raw_title); + if title.is_empty() { + continue; + } + + let thumb = card + .select(&img_sel) + .next() + .and_then(|img| img.value().attr("src")) + .unwrap_or_default() + .to_string(); + + let card_html = card.html(); + let duration = dur_re + .captures(&card_html) + .and_then(|c| c.get(1)) + .map(|m| Self::parse_duration(m.as_str())) + .unwrap_or(0); + + let mut item = VideoItem::new( + public_id, + title, + destination_url.clone(), + CHANNEL_ID.to_string(), + thumb, + duration, + ); + + if Self::is_hard_host(host) { + let proxy_target = strip_url_scheme(&destination_url); + let proxy_url = build_proxy_url(options, CHANNEL_ID, &proxy_target); + if !proxy_url.is_empty() { + let mut format = crate::videos::VideoFormat::new( + proxy_url, + "auto".to_string(), + "video/mp4".to_string(), + ); + format.add_http_header("Referer".to_string(), destination_url.clone()); + item = item.formats(vec![format]); + } + } + + if let Some(ts) = ago_re + .captures(&card_html) + .and_then(|c| c.get(1)) + .and_then(|m| Self::parse_relative_ago(m.as_str())) + { + item = item.uploaded_at(ts); + } + + if let Some(source_link) = card.select(&source_sel).next() { + let source_href = source_link.value().attr("href").unwrap_or_default(); + let slug = source_href.trim_start_matches("/source/").to_string(); + let name = source_link.text().collect::().trim().to_string(); + if !name.is_empty() { + item = item.uploader(name); + if !slug.is_empty() { + item = item.uploader_url(format!("{BASE_URL}/source/{slug}")); + item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}")); + } + } + } + + let mut models: Vec = card + .select(&model_sel) + .filter_map(|m| m.value().attr("title")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + models.sort(); + models.dedup(); + if !models.is_empty() { + item = item.tags(models); + } + + items.push(item); + } + + items + } + + fn resolve_target(query: &str, sort: &str, page: u32) -> Target { + if !query.is_empty() { + return Target::Search { + query: query.to_string(), + page, + }; + } + match sort { + "popular" | "top" | "views" => Target::Popular { page }, + "rating" | "best" | "top_rated" => Target::Rating { page }, + _ => Target::Latest { page }, + } + } + + async fn fetch_and_parse( + &self, + cache: VideoCache, + target: Target, + options: ServerOptions, + ) -> Result> { + let url = Self::build_url(&target); + + if let Some((time, items)) = cache.get(&url) { + if time.elapsed().unwrap_or_default().as_secs() < 300 { + return Ok(items.clone()); + } + } + + let mut requester = requester_or_default(&options, CHANNEL_ID, "fetch_and_parse"); + + let html = requester + .get_with_headers( + &url, + vec![ + ("user-agent".to_string(), BROWSER_UA.to_string()), + ( + "accept".to_string(), + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(), + ), + ("accept-language".to_string(), "en-US,en;q=0.5".to_string()), + ], + Some(wreq::Version::HTTP_11), + ) + .await + .map_err(|e| Error::from(format!("request failed url={url}: {e}")))?; + + if html.is_empty() { + return Ok(vec![]); + } + + let items = self.parse_listing(&html, &options); + + if !items.is_empty() { + cache.insert(url, items.clone()); + } + + Ok(items) + } +} + +#[async_trait] +impl Provider for MelonstubeProvider { + async fn get_videos( + &self, + cache: VideoCache, + pool: DbPool, + sort: String, + query: Option, + page: String, + per_page: String, + options: ServerOptions, + ) -> Vec { + let _ = pool; + let _ = per_page; + let page = page.parse::().unwrap_or(1); + let query_str = query.unwrap_or_default(); + let target = Self::resolve_target(&query_str, &sort, page); + + match self.fetch_and_parse(cache, target, options).await { + Ok(items) => items, + Err(e) => { + report_provider_error(CHANNEL_ID, "get_videos", &e.to_string()).await; + vec![] + } + } + } + + fn get_channel(&self, clientversion: ClientVersion) -> Option { + Some(self.build_channel(clientversion)) + } +} + +#[cfg(test)] +mod tests { + use super::MelonstubeProvider; + use crate::videos::ServerOptions; + + fn make_options() -> ServerOptions { + ServerOptions { + client_version: None, + featured: None, + category: None, + sites: None, + filter: None, + language: None, + public_url_base: Some("http://127.0.0.1:18080".to_string()), + requester: None, + network: None, + stars: None, + categories: None, + duration: None, + sort: None, + sexuality: None, + } + } + + #[test] + fn builds_latest_urls() { + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Latest { page: 1 }), + "https://www.melonstube.com/new" + ); + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Latest { page: 2 }), + "https://www.melonstube.com/new?page=2" + ); + } + + #[test] + fn builds_popular_and_rating_urls() { + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Popular { page: 1 }), + "https://www.melonstube.com/popular" + ); + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Rating { page: 3 }), + "https://www.melonstube.com/rating?page=3" + ); + } + + #[test] + fn builds_search_urls() { + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Search { + query: "teen".to_string(), + page: 1 + }), + "https://www.melonstube.com/search?q=teen" + ); + assert_eq!( + MelonstubeProvider::build_url(&super::Target::Search { + query: "teen".to_string(), + page: 2 + }), + "https://www.melonstube.com/search?q=teen&page=2" + ); + } + + #[test] + fn parses_duration() { + assert_eq!(MelonstubeProvider::parse_duration("28:30"), 1710); + assert_eq!(MelonstubeProvider::parse_duration("1:02:03"), 3723); + } + + #[test] + fn parses_relative_ago() { + assert!(MelonstubeProvider::parse_relative_ago("2 minutes ago").is_some()); + assert!(MelonstubeProvider::parse_relative_ago("garbage").is_none()); + } + + #[test] + fn decodes_real_out_link() { + let href = "/out/?l=3AASFc4gFQqQq3JyVWJiN2xYa1FHAtmxaHR0cHM6Ly93d3cuNDI5bWVuLmNvbS92aWRlb3MvNDY3NTg2L3Vuc2Vlbi1tb21lbnRzLW9mLWVjc3Rhc3ktd2l0aC1qaWxsLWthc3NpZHktdmluYS1za3ktYW5kLW5hb21pLXN3YW5uLz91dG1fc291cmNlPWF3bSZ1dG1fbWVkaXVtPWF3bXRyYWZmaWMmdXRtX2NhbXBhaWduPTQyOW1lbiZzdWJpZDE9NjAwMDAxzQNeonRjAc0H2qRkYXRlAdkweyJhbGwiOiIiLCJvcmllbnRhdGlvbiI6InN0cmFpZ2h0IiwicHJpY2luZyI6IiJ9wM5qS9cBwM4EMO42wNk9W3siMSI6IjA4Wlk3ZkpHb3MzIn0seyIyIjoiQTlVUWQ2MGxOem8ifSx7IjMiOiIwNlJrNVhmSXVhWiJ9XQ%3D%3D&c=abc123&v=3&"; + let (destination, id) = MelonstubeProvider::decode_out_link(href).expect("decodes"); + assert!(destination.starts_with("https://www.429men.com/videos/467586/")); + assert_eq!(id, "abc123"); + } + + #[test] + fn classifies_hosts() { + assert!(MelonstubeProvider::is_hard_host("manysex.com")); + assert!(MelonstubeProvider::is_hard_host("videomanysex.com")); + assert!(!MelonstubeProvider::is_hard_host("www.429men.com")); + assert!(MelonstubeProvider::is_junk_host("fhgte.com")); + assert!(MelonstubeProvider::is_junk_host("join.nastyclub.com")); + assert!(!MelonstubeProvider::is_junk_host("www.429men.com")); + } + + #[test] + fn parses_cards_from_html() { + let html = r##" + +
+ + +
429Men2 minutes ago
+
+ + "##; + + let provider = MelonstubeProvider::new(); + let options = make_options(); + let items = provider.parse_listing(html, &options); + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!(item.id, "rrUbb7lXkQG"); + assert_eq!(item.title, "Unseen Moments"); + assert_eq!(item.duration, 1710); + assert!(item.url.starts_with("https://www.429men.com/videos/467586/")); + assert!(item.formats.is_none()); + assert_eq!(item.uploader.as_deref(), Some("429Men")); + assert_eq!(item.tags.as_deref(), Some(&["Jill Kassidy".to_string()][..])); + assert!(item.uploadedAt.is_some()); + } + + #[test] + fn hard_host_card_gets_proxy_format() { + let html = r##" + + + + "##; + + let provider = MelonstubeProvider::new(); + let options = make_options(); + let items = provider.parse_listing(html, &options); + assert_eq!(items.len(), 1); + let item = &items[0]; + assert!(item.url.starts_with("https://videomanysex.com/video/3519093/")); + let formats = item.formats.as_ref().expect("hard host must proxy"); + assert!( + formats[0] + .url + .starts_with("http://127.0.0.1:18080/proxy/melonstube/videomanysex.com/") + ); + } +} diff --git a/src/providers/redgifs.rs b/src/providers/redgifs.rs index 12dc207..80ce0eb 100644 --- a/src/providers/redgifs.rs +++ b/src/providers/redgifs.rs @@ -14,7 +14,7 @@ use tokio::sync::RwLock; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { - group_id: "amateur-homemade", + group_id: "tiktok", tags: &["amateur", "gifs", "creators"], }; diff --git a/src/proxies/melonstube.rs b/src/proxies/melonstube.rs new file mode 100644 index 0000000..f75af96 --- /dev/null +++ b/src/proxies/melonstube.rs @@ -0,0 +1,251 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ntex::web::{self, HttpRequest}; +use regex::Regex; +use serde::Deserialize; +use wreq::redirect::Policy; +use wreq_util::Emulation; + +use crate::util::requester::Requester; + +const API_BASE: &str = "https://manysex.com"; +const BROWSER_UA: &str = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +#[derive(Debug, Deserialize)] +struct VideofileEntry { + video_url: String, + #[serde(default)] + is_default: u8, +} + +#[derive(Debug, Clone)] +pub struct MelonstubeProxy {} + +impl MelonstubeProxy { + pub fn new() -> Self { + Self {} + } + + /// Accepts the manysex.com/videomanysex.com detail page URL (host+path + /// reconstructed from the `/proxy/melonstube/{endpoint}*` route) and + /// validates the host against the known hard-site allowlist. Returns + /// `None` rather than guessing for anything else. + fn normalize_detail_url(endpoint: &str) -> Option { + let value = endpoint.trim().trim_start_matches('/'); + if value.is_empty() { + return None; + } + let detail_url = if value.starts_with("http://") || value.starts_with("https://") { + value.to_string() + } else { + format!("https://{value}") + }; + let detail_url = detail_url.replacen("http://", "https://", 1); + let parsed = url::Url::parse(&detail_url).ok()?; + let host = parsed.host_str()?; + if !(host == "manysex.com" || host == "videomanysex.com") { + return None; + } + Some(detail_url) + } + + fn extract_video_id(detail_url: &str) -> Option { + let regex = Regex::new(r"/video/(\d+)/").ok()?; + Some(regex.captures(detail_url)?.get(1)?.as_str().to_string()) + } + + // manysex.com obfuscates the `get_file` path/query with a handful of + // Cyrillic homoglyphs standing in for visually identical Latin letters, + // before base64-encoding. Ported verbatim from vjav.rs's decode chain. + fn decode_obfuscated_base64(value: &str) -> String { + value + .chars() + .map(|character| match character { + 'А' => 'A', + 'В' => 'B', + 'Е' => 'E', + 'К' => 'K', + 'М' => 'M', + 'Н' => 'H', + 'О' => 'O', + 'Р' => 'P', + 'С' => 'C', + 'Т' => 'T', + 'Х' => 'X', + 'а' => 'a', + 'е' => 'e', + 'о' => 'o', + 'р' => 'p', + 'с' => 'c', + 'у' => 'y', + 'х' => 'x', + 'к' => 'k', + 'м' => 'm', + 'і' => 'i', + 'І' => 'I', + _ => character, + }) + .collect() + } + + fn decode_base64ish(value: &str) -> Option { + let mut normalized = value.trim().replace('~', "="); + while normalized.len() % 4 != 0 { + normalized.push('='); + } + String::from_utf8(STANDARD.decode(normalized).ok()?).ok() + } + + /// Decodes the obfuscated `video_url` field from `videofile.php` into the + /// `get_file` path and query. Unlike vjav (whose decoded path is directly + /// playable as an `.m3u8`), manysex.com's decoded path only resolves to a + /// playable CDN URL after two additional redirect hops (see + /// `resolve_stream_url`). + fn decode_get_file_path(value: &str) -> Option<(String, String)> { + let normalized = Self::decode_obfuscated_base64(value); + let mut parts = normalized.splitn(2, ','); + let path_part = parts.next()?; + let query_part = parts.next()?; + let path = Self::decode_base64ish(path_part)?; + let query = Self::decode_base64ish(query_part)?; + Some((path, query)) + } + + fn redirect_client() -> Option { + wreq::Client::builder() + .cert_verification(false) + .emulation(Emulation::Chrome120) + .redirect(Policy::default()) + .build() + .ok() + } + + fn no_redirect_client() -> Option { + wreq::Client::builder() + .cert_verification(false) + .emulation(Emulation::Chrome120) + .redirect(Policy::none()) + .build() + .ok() + } + + /// Resolves a manysex.com/videomanysex.com detail page URL to a playable + /// CDN URL. Each hop is made manually with a per-hop Referer: the CDN's + /// signed URL embeds a literal `referer=none,.manysex.com,.gstatic.com` + /// allow-list, so blindly forwarding the same Referer to every hop (as + /// automatic-redirect clients do) gets rejected with 403 at hop 2. + /// Before returning, a ranged GET on the final URL verifies it is + /// actually a video stream (not an ad/error page masquerading as one). + async fn resolve_stream_url(detail_url: &str) -> Option { + let video_id = Self::extract_video_id(detail_url)?; + + let api_client = Self::redirect_client()?; + let api_url = format!("{API_BASE}/api/videofile.php?video_id={video_id}&lifetime=8640000"); + let response = api_client + .get(&api_url) + .header("user-agent", BROWSER_UA) + .header("referer", detail_url) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let entries: Vec = response.json().await.ok()?; + let entry = entries + .iter() + .find(|e| e.is_default == 1) + .or_else(|| entries.first())?; + if entry.video_url.trim().is_empty() { + return None; + } + + let (path, query) = Self::decode_get_file_path(&entry.video_url)?; + let separator = if path.contains('?') { "&" } else { "?" }; + let get_file_url = format!("{API_BASE}{path}{separator}{query}"); + + let hop_client = Self::no_redirect_client()?; + + // Hop 1: the videomanysex.com/manysex.com page Referer is required here. + let hop1 = hop_client + .get(&get_file_url) + .header("user-agent", BROWSER_UA) + .header("referer", detail_url) + .send() + .await + .ok()?; + if !hop1.status().is_redirection() { + return None; + } + let hop2_url = hop1.headers().get("location")?.to_str().ok()?.to_string(); + + // Hop 2: the CDN allow-list rejects the videomanysex.com Referer used + // above, so this hop must be sent with no Referer at all. + let hop2 = hop_client + .get(&hop2_url) + .header("user-agent", BROWSER_UA) + .send() + .await + .ok()?; + if !hop2.status().is_redirection() { + return None; + } + let final_url = hop2.headers().get("location")?.to_str().ok()?.to_string(); + + // Anti-false-positive check: confirm the resolved URL actually + // references the requested video id, and that a small ranged fetch + // returns real video content rather than an ad/error page. + if !final_url.contains(&video_id) { + return None; + } + let verify = hop_client + .get(&final_url) + .header("user-agent", BROWSER_UA) + .header("range", "bytes=0-65535") + .send() + .await + .ok()?; + if !(verify.status().as_u16() == 200 || verify.status().as_u16() == 206) { + return None; + } + let content_type = verify + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + if !content_type.starts_with("video/") && !content_type.contains("octet-stream") { + return None; + } + + Some(final_url) + } +} + +/// Route handler for `/proxy/melonstube/{endpoint}*`. +/// +/// Resolves a manysex.com/videomanysex.com detail page URL (melonstube's +/// out-link destination) to a playable CDN URL and returns a 302 redirect. +pub async fn serve( + req: HttpRequest, + _requester: web::types::State, +) -> Result { + let endpoint = req.match_info().query("endpoint").to_string(); + let query_string = req.query_string(); + let raw = if query_string.is_empty() { + endpoint + } else { + format!("{endpoint}?{query_string}") + }; + + let Some(detail_url) = MelonstubeProxy::normalize_detail_url(&raw) else { + return Ok(web::HttpResponse::BadRequest().finish()); + }; + + match MelonstubeProxy::resolve_stream_url(&detail_url).await { + Some(final_url) => Ok(web::HttpResponse::Found() + .header("Location", final_url) + .finish()), + None => Ok(web::HttpResponse::BadGateway().finish()), + } +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index e63c662..5ac33ca 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -18,6 +18,7 @@ use crate::proxies::lulustream::LulustreamProxy; use crate::proxies::thaiporntv::ThaipornTvProxy; pub mod allpornstream; +pub mod melonstube; pub mod animeidhentai; pub mod archivebate; pub mod clapdat; diff --git a/src/proxy.rs b/src/proxy.rs index 20376c9..76935b8 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -184,6 +184,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(crate::proxies::allpornstream::serve)) .route(web::get().to(crate::proxies::allpornstream::serve)), ); + cfg.service( + web::resource("/melonstube/{endpoint}*") + .route(web::post().to(crate::proxies::melonstube::serve)) + .route(web::get().to(crate::proxies::melonstube::serve)), + ); } async fn proxy2redirect(