melonstube

This commit is contained in:
Simon
2026-07-06 17:29:04 +00:00
parent b753863d30
commit 94cc3d7bbb
7 changed files with 885 additions and 1 deletions

View File

@@ -321,6 +321,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "allpornstream",
ty: "AllPornStreamProvider",
},
ProviderDef {
id: "melonstube",
module: "melonstube",
ty: "MelonstubeProvider",
},
ProviderDef {
id: "tube8",
module: "tube8",

View File

@@ -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=<base64>&c=<hash>&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. |

621
src/providers/melonstube.rs Normal file
View File

@@ -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<u32> = text
.trim()
.split(':')
.filter_map(|p| p.parse::<u32>().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<u64> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let regex = Regex::new(
r"(?i)^(?P<amount>\d+)\s+(?P<unit>second|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::<i64>().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=<b64-msgpack>&c=<hash>&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<VideoItem> {
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*</span>"#) {
Ok(r) => r,
Err(_) => return vec![],
};
let ago_re = match Regex::new(r#"</a><span class="item-source[^"]*">([^<]+)</span>"#) {
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::<String>().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<String> = 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<Vec<VideoItem>> {
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<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = pool;
let _ = per_page;
let page = page.parse::<u32>().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<Channel> {
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##"
<html><body>
<div class="card sub group relative block space-y-1" data-public-id="rrUbb7lXkQG">
<a class="item-link rate-link relative rounded h-0 overflow-hidden block pb-[56.25%]" href="/out/?l=3AASFc4gFQqQq3JyVWJiN2xYa1FHAtmxaHR0cHM6Ly93d3cuNDI5bWVuLmNvbS92aWRlb3MvNDY3NTg2L3Vuc2Vlbi1tb21lbnRzLW9mLWVjc3Rhc3ktd2l0aC1qaWxsLWthc3NpZHktdmluYS1za3ktYW5kLW5hb21pLXN3YW5uLz91dG1fc291cmNlPWF3bSZ1dG1fbWVkaXVtPWF3bXRyYWZmaWMmdXRtX2NhbXBhaWduPTQyOW1lbiZzdWJpZDE9NjAwMDAxzQNeonRjAc0H2qRkYXRlAdkweyJhbGwiOiIiLCJvcmllbnRhdGlvbiI6InN0cmFpZ2h0IiwicHJpY2luZyI6IiJ9wM5qS9cBwM4EMO42wNk9W3siMSI6IjA4Wlk3ZkpHb3MzIn0seyIyIjoiQTlVUWQ2MGxOem8ifSx7IjMiOiIwNlJrNVhmSXVhWiJ9XQ%3D%3D&c=abc123&v=3&" target="_blank" title="&#8221;Unseen Moments&#8221;" tabindex="-1" rel="nofollow">
<img class="item-image relative w-full h-full object-cover" src="https://c4.ttcache.com/thumbnail/rrUbb7lXkQG/288x162/1.jpg">
<span class="item-meta-container absolute bottom-1 right-1"><span class="badge float-end bg-zinc-100/80 dark:bg-zinc-800/80 inline-block px-1 text-sm rounded">28:30</span></span>
</a>
<div class="dropdown-menu-footer">
<a class="menu-item anchor-link gap-3 pornstar-label" href="/search/Jill%20Kassidy" title="Jill Kassidy"><i class="icon-start far fa-person"></i>Jill Kassidy</a>
</div>
<div class="item-source-rating-container"><a class="item-source block text-xsm truncate text-dimmed" href="/source/429men"><i class="far fa-badge-check text-xsm"></i>429Men</a><span class="item-source block text-xsm text-dimmed">2 minutes ago</span></div>
</div>
</body></html>
"##;
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##"
<html><body>
<div class="card sub group relative block space-y-1" data-public-id="hardHostId1">
<a class="item-link rate-link" href="/out/?l=o3hVOWh0dHBzOi8vdmlkZW9tYW55c2V4LmNvbS92aWRlby8zNTE5MDkzL3RlcmEtbGluay8AZXh0cmE%3D&c=def456&v=3&" target="_blank" title="Tera Link">
<img class="item-image" src="https://c4.ttcache.com/thumbnail/hardHostId1/288x162/1.jpg">
<span class="item-meta-container"><span class="badge">8:00</span></span>
</a>
</div>
</body></html>
"##;
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/")
);
}
}

View File

@@ -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"],
};

251
src/proxies/melonstube.rs Normal file
View File

@@ -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<String> {
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<String> {
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<String> {
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> {
wreq::Client::builder()
.cert_verification(false)
.emulation(Emulation::Chrome120)
.redirect(Policy::default())
.build()
.ok()
}
fn no_redirect_client() -> Option<wreq::Client> {
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<String> {
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<VideofileEntry> = 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<Requester>,
) -> Result<impl web::Responder, web::Error> {
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()),
}
}

View File

@@ -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;

View File

@@ -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(