melonstube
This commit is contained in:
5
build.rs
5
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",
|
||||
|
||||
@@ -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
621
src/providers/melonstube.rs
Normal 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="”Unseen Moments”" 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/")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
251
src/proxies/melonstube.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user