eporner: stream through the server; the signed CDN links are IP-locked
The redirect proxy from 6ba65f1 still played the one-minute "watch on
eporner.com" clip away from home: checked from foreign networks
(check-host.net nodes in BR/DE/HU), a freshly signed link answers 302 ->
static.eporner.com/na.mp4, while the IP that resolved it gets the file. A
redirect therefore only works for a player behind the server's public IP.
/proxy/eporner/{alnum_id}/{height}.mp4 now streams the mp4: resolves the
link (cached 90 min, re-resolved when the CDN answers with a redirect),
forwards Range, and returns a sized body so 206 responses keep
Content-Length. It is no longer a redirect proxy, so app clients keep the
URLs in formats. Verified locally: HEAD, 0-1, mid-file and open-ended ranges
return 206 with Content-Length/Content-Range and valid MP4 data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
@@ -83,7 +83,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
||||
| `fullporner` | `mainstream-tube` | no | no | HTML scraper for fullporner.com; thumbnail IDs derived from `/thumb/{id}.jpg` URLs and used to build direct `xiaoshenke.net/vid/{id}/720` media redirect URLs (Referer + User-Agent headers required); supports cat:/category:/pornstar:/star: shortcut queries; no proxy needed. |
|
||||
| `cumlouder` | `mainstream-tube` | no | no | HTML scraper for cumlouder.com (Spanish/amateur tube, same platform family as superporn). Cards are `a.muestra-escena[href^="/porn-video/"]` with lazy thumb in `img[data-src]` (protocol-relative `//th0cdn.cumlouder.com/...`, direct, no Referer/proxy), `h2` title, `.vistas` views, `.minutos` duration (`06:14 m`); relative date only ("2 years ago"), so `uploadedAt` is not set. Default feed `/porn/` (page N≥2 is the bare `/N/` path, not `/porn/N/`); category `/porn-videos/{slug}/{N}/`; tag `/tag/{slug}/{N}/`; series `/series/{slug}/{N}/`. **The site's own `/search/` (POST `form[search]` or `?q=`) is behind a Cloudflare managed challenge — 403 even in a real browser — so keyword queries are routed instead: a query matching a hardcoded category (72, scraped from `/categories/`) goes to the category archive, anything else to `/tag/{slug}/` (unknown tags 404 → empty list).** Pornstar pages live at `/girl/{slug}/{N}/` (same cards/pagination; unknown slug = fast 404). A free-form keyword that is not a category is tried as a pornstar page first, then as a tag archive; `girl:`/`pornstar:`/`star:`/`model:` force the pornstar page. Supports `cat:`/`category:`, `tag:`, `series:` prefixes and the `categories` option. A Cloudflare challenge on the raw listing fetch is handed to the requester's FlareSolverr chain. `video.url` is the detail page, which yt-dlp resolves natively via the generic html5 extractor (the page embeds a signed `m5cdnst.cumlouder.com/*.mp4?secure=…` `<source>`, no Referer needed), so no `formats`/enrichment. The origin intermittently answers a first, uncached request with a Cloudflare 522 (a retry succeeds) — the listing fetch retries up to 3×, and `check.py`/yt-dlp may show a one-off `HTTP=-1`/522 on a cold detail page that is not a provider fault. No proxy needed. |
|
||||
| `thepornbunny` | `mainstream-tube` | no | yes | KVS-style HTML scraper for thepornbunny.com; 24 items per site page; thumbnails at `https://www.thepornbunny.com/images/thumb/{id}.webp` from `data-original` attribute (no proxy needed); studio exposed as uploader; pornstar names in tags; `/proxy/thepornbunny/{slug}` fetches the video page, extracts `generate_mp4(enc_data, key, rnd, video_id)` args, decrypts `enc_data` via PBKDF2-HMAC-SHA512+AES-256-CBC to get an OK.ru session key, calls `api.ok.ru/fb.do?method=video.get&session_key=KEY&vids=RND` to get signed CDN URLs, and returns 302 to the best-quality okcdn.ru/vkuser.net MP4 URL (no special client headers needed); supports sort: new/popular/rated, 20 hardcoded categories via `categories` option, and tag:/category:/studio:/pornstar: query shortcuts. |
|
||||
| `eporner` | `mainstream-tube` | no | yes | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=<id>` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. **2026-10-22:** the signed CDN URLs expire exactly two hours after the `/xhr/video` call (the leading number in `/{expiry}_{ip}_{n}/`; the embedded IP is not enforced) and an expired link gets the same `na.mp4` decoy, so favourites, history and long-open lists played the one-minute clip. Formats now point at the redirect proxy `/proxy/eporner/{alnum_id}/{height}`, which re-resolves at play time and 302s to the matching (else best) quality; verified app clients get the best one as `video.url` per the redirect-proxy rule. melonstube's eporner destinations use the same `resolve_proxied_formats`. |
|
||||
| `eporner` | `mainstream-tube` | no | yes | HTML scraper for eporner.com (5M+ videos); card selector `div.mb[data-id]` with inline duration/rating/views/uploader; thumbnails at `static-eu-cdn.eporner.com` (no proxy needed); pagination uses `/{N}/` suffix (page 1 = no suffix, page 2 = `/2/`); supports sort: new/popular/rated/best; 65 hardcoded categories, plus `cat:`/`category:`, `tag:`, `pornstar:`/`star:`, `uploader:`/`profile:` query-prefix routing to the matching archive, a bare-keyword category-name match, and a background-loaded pornstar name→URL map from `/pornstar-list/` (falls back to `/tag/{slug}` keyword search otherwise). `video.url` is the detail page — **not** yt-dlp-resolvable and not directly playable on its own: opening it bare serves a small restricted/preview clip. Real playback needs per-card enrichment (bounded `buffer_unordered(6)`): fetch the detail page for its 32-hex `hash`, derive the site's own hashed token (`calc_hash` — split into four 8-hex chunks, each parsed as u32 and base36-encoded, concatenated; reverse-engineered from the player JS), then call the site's own `GET /xhr/video/{alnum_id}?hash={calc}&device=generic&domain=www.eporner.com&fallback=false` JSON API for direct per-resolution mp4 URLs into `formats[]`. Separately from the hash dance, the resolved CDN mp4 URLs enforce their own Referer check: a request with no Referer or one pointing at eporner.com gets the real file, but any other non-empty Referer gets a 302 to a static `.../na.mp4` ("not available") decoy clip — the exact "this video is only available at eporner.com"-style short clip a client sees if its default (or app-injected) Referer doesn't match, so every format explicitly declares `http_headers: {Referer: "https://www.eporner.com/"}` rather than relying on the client's default. No proxy needed (formats are direct CDN URLs, just header-gated). **2026-09-21:** the `calc_hash`/hash-regex/`alnum_id_from_href`/`resolve_formats` logic moved to `src/util/eporner_resolve.rs` so `melonstube` (a meta-search aggregator that redirects out to eporner.com for ~34% of its own cards) can reuse the exact same resolution without a cross-provider `use`, which would break either provider's `HOT_TUB_PROVIDER=<id>` single-provider build (only the one selected provider module gets compiled; `src/util/*` always does). `eporner.rs` itself now just calls `crate::util::eporner_resolve::resolve_formats(...)`. **2026-10-22:** the signed CDN URLs expire exactly two hours after the `/xhr/video` call (the leading number in `/{expiry}_{ip}_{n}/`) and an expired link gets the same `na.mp4` decoy, so favourites, history and long-open lists played the one-minute clip. The links are also **IP-locked**: checked from foreign networks (check-host.net nodes) a fresh link answers 302 → `na.mp4`, while the resolving IP gets the file, so a redirect only works for a player behind the server's own public IP (a Jina fetch had misleadingly suggested otherwise). Formats therefore point at the streaming proxy `/proxy/eporner/{alnum_id}/{height}.mp4`: it resolves (cached 90 min per id/height, re-resolved when the CDN answers with a redirect), forwards `Range`, and returns a sized body so `206` responses keep `Content-Length` for AVPlayer. Not a redirect proxy, so app clients keep these in `formats`. All eporner playback bandwidth now flows through the server. melonstube's eporner destinations use the same `resolve_proxied_formats`. |
|
||||
| `xnxx` | `mainstream-tube` | no | no | HTML scraper for xnxx.com (10M+ videos); unified card parser handles two formats: `div.thumb-block[data-eid]` (search) and `div.thumb-block.video[data-video='{"id":...}']` (hits); eid extracted from `/video-{eid}/{slug}` URL path; thumbnails at `thumb-cdn77.xnxx-cdn.com` and `thumbs-gcore.xnxx-cdn.com` (no proxy, no Referer needed); 0-indexed pagination (page 1 = `/hits`, page N = `/hits/{N-1}`); default feed is `/hits` (most-viewed — xnxx has no chronological listing); search via `/search/{slug}` (works for keywords and tags); supports `tag:`, `cat:`, `category:` query shortcuts; yt-dlp resolves `video.url` natively (XNXX extractor, returns 4-7 HLS formats); no proxy needed. |
|
||||
| `xgroovy` | `mainstream-tube` | no | no | KVS (Kernel Video Sharing)-style HTML scraper for xgroovy.com; card selector `div.item[data-video-id]` with `data-views`/`data-rating` as clean numeric attributes directly on the card (no text-parsing needed, unlike xhamster); title/url from `a.popito[href] > strong.title`; thumbnails at `i.xgroovy.com` (no proxy, no Referer needed) from `img.thumb[data-jpg|src]`, preview clip from `img[data-preview]`; duration rendered as free text ("N min"/"N sec", not colon-separated) via a bespoke `parse_duration` helper; path-segment pagination (`/new/` = page 1, `/new/2/` = page 2, unlike xhamster's `?page=N` query string); feeds: `/new/` (default), categories via `/categories/{slug}/` (73 hardcoded categories as `categories` option), pornstars via `/pornstars/{slug}/`, channels via `/channels/{slug}/`; search via `/search/{dashed-query}/` (multi-word queries are lowercased and dash-joined); uploader kind inferred from URL path (`/pornstars/` vs `/channels/`) via `div.author-link > a`; supports `cat:`/`category:` query shortcuts plus static category name matching; yt-dlp resolves `video.url` via the generic html5-embed extractor (no dedicated xgroovy extractor, but resolves cleanly to a 1080p format); no proxy needed. |
|
||||
| `xhamster` | `mainstream-tube` | no | no | HTML scraper for xhamster.com; card selector `div[data-video-type="video"]` with `data-video-id`; thumbnails via `img[data-role="thumb-preview-img"]` at `ic-vt-nss.xhcdn.com` (no proxy, no Referer needed); pagination via `?page=N` query param (browse feeds use infinite-scroll so only search reliably returns different content per page); feeds: `/newest` (default), `/most-viewed`, `/best`; categories via `/categories/{slug}`; channels via `/channels/{slug}`; 43 hardcoded categories as `categories` option; uploader type inferred from URL path (`/channels/` → channel, `/creators/` → creator, `/pornstars/` → pornstar); supports `cat:`/`category:` and `channel:` query shortcuts, plus static category name matching; preview mp4 clips from `data-previewvideo` attribute; yt-dlp resolves `video.url` natively (xHamster extractor, 28 formats); no proxy needed. |
|
||||
@@ -116,7 +116,6 @@ These resolve a provider-specific input into a `302 Location`.
|
||||
- `/proxy/allpornstream/{endpoint}*`
|
||||
- `/proxy/tube8/{endpoint}*`
|
||||
- `/proxy/supjav/{endpoint}*`
|
||||
- `/proxy/eporner/{alnum_id}/{height}` (fresh signed mp4 per play; the CDN links expire after 2h)
|
||||
- `/proxy/jable/{slug}*`
|
||||
- `/proxy/thepornbunny/{slug}*`
|
||||
- `/proxy/animeidhentai/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL)
|
||||
@@ -127,6 +126,7 @@ These resolve a provider-specific input into a `302 Location`.
|
||||
These return binary media or images, sometimes rewriting manifests or forwarding cookies/referers.
|
||||
|
||||
- `/proxy/shooshtime-media/{endpoint}*`
|
||||
- `/proxy/eporner/{alnum_id}/{height}.mp4` (streams the IP-locked signed mp4 with Range passthrough; link cached 90 min)
|
||||
- `/proxy/noodlemagazine/{endpoint}*`
|
||||
- `/proxy/noodlemagazine-thumb/{endpoint}*`
|
||||
- `/proxy/hanime-cdn/{endpoint}*`
|
||||
|
||||
@@ -450,8 +450,9 @@ impl EpornerProvider {
|
||||
// redirect out to eporner.com, e.g. `melonstube`) rather than here,
|
||||
// since single-provider builds can't depend on another provider's
|
||||
// module — see that module's doc comment.
|
||||
// Formats point at the eporner redirect proxy: the CDN links expire two
|
||||
// hours after resolving and then play a "watch on eporner.com" clip.
|
||||
// Formats point at the eporner streaming proxy: the CDN links expire
|
||||
// after two hours and only serve the server's IP; anything else plays
|
||||
// a one-minute "watch on eporner.com" clip.
|
||||
let formats = crate::util::eporner_resolve::resolve_proxied_formats(
|
||||
requester,
|
||||
options,
|
||||
|
||||
@@ -494,8 +494,8 @@ impl MelonstubeProvider {
|
||||
Self::parse_kvs_formats(&html, &origin)
|
||||
}
|
||||
|
||||
/// Through the eporner redirect proxy: its CDN links expire two hours
|
||||
/// after resolving and then play a "watch on eporner.com" clip.
|
||||
/// Through the eporner streaming proxy: its CDN links expire after two
|
||||
/// hours and only serve the server's IP (else a "watch on eporner.com" clip).
|
||||
async fn resolve_eporner(
|
||||
requester: &mut Requester,
|
||||
options: &ServerOptions,
|
||||
|
||||
@@ -1,65 +1,196 @@
|
||||
use ntex::web;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::StreamExt;
|
||||
use http_body_util::BodyExt;
|
||||
use ntex::http::Method;
|
||||
use ntex::http::body::{Body, SizedStream};
|
||||
use ntex::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
||||
use ntex::util::Bytes;
|
||||
use ntex::web::{self, HttpRequest};
|
||||
use wreq::Client;
|
||||
use wreq::redirect::Policy;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
use crate::util::eporner_resolve::resolve_ranked;
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EpornerProxy {}
|
||||
/// The signed CDN links expire two hours after resolving; renew well before.
|
||||
const LINK_TTL: Duration = Duration::from_secs(90 * 60);
|
||||
const LINK_CACHE_SIZE: usize = 2000;
|
||||
|
||||
impl EpornerProxy {
|
||||
pub fn new() -> Self {
|
||||
EpornerProxy {}
|
||||
}
|
||||
/// `{alnum_id}/{height}` -> (resolved at, signed CDN URL).
|
||||
fn links() -> &'static Mutex<HashMap<String, (Instant, String)>> {
|
||||
static LINKS: OnceLock<Mutex<HashMap<String, (Instant, String)>>> = OnceLock::new();
|
||||
LINKS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// `{alnum_id}[/{height}]`.
|
||||
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
|
||||
let mut parts = endpoint.trim_matches('/').split('/');
|
||||
let alnum_id = parts.next().filter(|id| {
|
||||
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
})?;
|
||||
/// Redirects are not followed so the CDN's `na.mp4` decoy shows up as a 302.
|
||||
fn cdn_client() -> &'static Client {
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
Client::builder()
|
||||
.tls_cert_verification(false)
|
||||
.emulation(Emulation::Firefox151)
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.expect("eporner CDN client")
|
||||
})
|
||||
}
|
||||
|
||||
/// `{alnum_id}[/{height}][.mp4]`.
|
||||
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
|
||||
let endpoint = endpoint.trim_matches('/').trim_end_matches(".mp4");
|
||||
let mut parts = endpoint.split('/');
|
||||
let alnum_id = parts
|
||||
.next()
|
||||
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()))?;
|
||||
let height = parts.next().and_then(|h| h.parse::<u32>().ok());
|
||||
Some((alnum_id.to_string(), height))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a fresh signed mp4 at play time. The CDN URLs expire two hours
|
||||
/// after the `/xhr/video` call, and an expired one is answered with a 302
|
||||
/// to `static.eporner.com/na.mp4`, a one-minute "watch it on eporner.com"
|
||||
/// clip; so a stale listing, favourite or history entry plays that clip.
|
||||
pub async fn get_video_url(
|
||||
&self,
|
||||
endpoint: String,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> String {
|
||||
let Some((alnum_id, height)) = Self::parse_endpoint(&endpoint) else {
|
||||
return String::new();
|
||||
};
|
||||
async fn resolve_link(
|
||||
requester: &web::types::State<Requester>,
|
||||
alnum_id: &str,
|
||||
height: Option<u32>,
|
||||
) -> Option<String> {
|
||||
let page_url = format!("https://www.eporner.com/video-{alnum_id}/");
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let Some(ranked) = resolve_ranked(&mut requester, &alnum_id, &page_url).await else {
|
||||
return String::new();
|
||||
};
|
||||
let ranked = resolve_ranked(&mut requester, alnum_id, &page_url).await?;
|
||||
height
|
||||
.and_then(|h| ranked.iter().find(|(height, _)| *height == h))
|
||||
.or_else(|| ranked.first())
|
||||
.map(|(_, format)| format.url.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn cached_link(
|
||||
requester: &web::types::State<Requester>,
|
||||
alnum_id: &str,
|
||||
height: Option<u32>,
|
||||
fresh: bool,
|
||||
) -> Option<String> {
|
||||
let key = format!("{alnum_id}/{}", height.unwrap_or(0));
|
||||
if !fresh {
|
||||
if let Ok(links) = links().lock() {
|
||||
if let Some((time, url)) = links.get(&key) {
|
||||
if time.elapsed() < LINK_TTL {
|
||||
return Some(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = resolve_link(requester, alnum_id, height).await?;
|
||||
if let Ok(mut links) = links().lock() {
|
||||
if links.len() >= LINK_CACHE_SIZE {
|
||||
links.retain(|_, (time, _)| time.elapsed() < LINK_TTL);
|
||||
if links.len() >= LINK_CACHE_SIZE {
|
||||
links.clear();
|
||||
}
|
||||
}
|
||||
links.insert(key, (Instant::now(), url.clone()));
|
||||
}
|
||||
Some(url)
|
||||
}
|
||||
|
||||
/// Streams the mp4 through this server. The signed CDN links only serve the
|
||||
/// IP that resolved them (checked from other networks: they answer every
|
||||
/// other client with a 302 to `static.eporner.com/na.mp4`, a one-minute
|
||||
/// "watch it on eporner.com" clip), so a redirect to them only ever works for
|
||||
/// a player that shares the server's public IP.
|
||||
pub async fn serve_media(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let Some((alnum_id, height)) = parse_endpoint(&endpoint) else {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
};
|
||||
let range = req
|
||||
.headers()
|
||||
.get(RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let head = req.method() == Method::HEAD;
|
||||
|
||||
let mut upstream = None;
|
||||
for fresh in [false, true] {
|
||||
let Some(url) = cached_link(&requester, &alnum_id, height, fresh).await else {
|
||||
break;
|
||||
};
|
||||
let mut request = if head {
|
||||
cdn_client().head(&url)
|
||||
} else {
|
||||
cdn_client().get(&url)
|
||||
};
|
||||
request = request.header("Referer", "https://www.eporner.com/");
|
||||
if let Some(range) = &range {
|
||||
request = request.header("Range", range);
|
||||
}
|
||||
match request.send().await {
|
||||
// A redirect is the decoy for an expired or foreign link.
|
||||
Ok(response) if !response.status().is_redirection() => {
|
||||
upstream = Some(response);
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
let Some(upstream) = upstream else {
|
||||
return Ok(web::HttpResponse::BadGateway().finish());
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let headers = upstream.headers().clone();
|
||||
let mut builder = web::HttpResponse::build(status);
|
||||
builder.header(
|
||||
CONTENT_TYPE,
|
||||
headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("video/mp4"),
|
||||
);
|
||||
builder.header(ACCEPT_RANGES, "bytes");
|
||||
if let Some(value) = headers.get(CONTENT_RANGE.as_str()).and_then(|v| v.to_str().ok()) {
|
||||
builder.header(CONTENT_RANGE, value);
|
||||
}
|
||||
if head {
|
||||
// `finish()` would send `Content-Length: 0` over the upstream length.
|
||||
return Ok(builder.body(Body::None));
|
||||
}
|
||||
let length = headers
|
||||
.get(CONTENT_LENGTH.as_str())
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok());
|
||||
let stream = Box::pin(upstream.into_data_stream().map(|chunk| {
|
||||
chunk
|
||||
.map(|bytes| Bytes::copy_from_slice(bytes.as_ref()))
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
}));
|
||||
let Some(length) = length else {
|
||||
return Ok(builder.streaming(stream));
|
||||
};
|
||||
// A sized body keeps `Content-Length` on the (206) responses; a chunked
|
||||
// one is what AVPlayer refuses to seek in.
|
||||
let stream = stream.map(|chunk| chunk.map_err(|e| Rc::new(e) as Rc<dyn std::error::Error>));
|
||||
Ok(builder.body(Body::from_message(SizedStream::new(length, stream))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EpornerProxy;
|
||||
use super::parse_endpoint;
|
||||
|
||||
#[test]
|
||||
fn parses_id_and_optional_height() {
|
||||
assert_eq!(
|
||||
EpornerProxy::parse_endpoint("exfeTXHuL1x/720"),
|
||||
parse_endpoint("exfeTXHuL1x/720.mp4"),
|
||||
Some(("exfeTXHuL1x".to_string(), Some(720)))
|
||||
);
|
||||
assert_eq!(
|
||||
EpornerProxy::parse_endpoint("/exfeTXHuL1x/"),
|
||||
parse_endpoint("/exfeTXHuL1x/"),
|
||||
Some(("exfeTXHuL1x".to_string(), None))
|
||||
);
|
||||
assert_eq!(EpornerProxy::parse_endpoint("../etc"), None);
|
||||
assert_eq!(parse_endpoint("../etc"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::proxies::archivebate::ArchivebateProxy;
|
||||
use crate::proxies::clapdat::ClapdatProxy;
|
||||
use crate::proxies::doodstream::DoodstreamProxy;
|
||||
use crate::proxies::eporner::EpornerProxy;
|
||||
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
||||
use crate::proxies::hqporner::HqpornerProxy;
|
||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||
@@ -73,7 +72,6 @@ pub enum AnyProxy {
|
||||
ThaipornTv(ThaipornTvProxy),
|
||||
Tube8(Tube8Proxy),
|
||||
Supjav(SupjavProxy),
|
||||
Eporner(EpornerProxy),
|
||||
}
|
||||
|
||||
pub trait Proxy {
|
||||
@@ -100,7 +98,6 @@ impl Proxy for AnyProxy {
|
||||
AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Tube8(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Supjav(p) => p.get_video_url(url, requester).await,
|
||||
AnyProxy::Eporner(p) => p.get_video_url(url, requester).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
src/proxy.rs
13
src/proxy.rs
@@ -3,7 +3,6 @@ use ntex::web::{self, HttpRequest};
|
||||
use crate::proxies::archivebate::ArchivebateProxy;
|
||||
use crate::proxies::clapdat::ClapdatProxy;
|
||||
use crate::proxies::doodstream::DoodstreamProxy;
|
||||
use crate::proxies::eporner::EpornerProxy;
|
||||
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
||||
use crate::proxies::hqporner::HqpornerProxy;
|
||||
use crate::proxies::javtiful::JavtifulProxy;
|
||||
@@ -44,7 +43,6 @@ pub const REDIRECT_PROXIES: &[&str] = &[
|
||||
"thaiporntv",
|
||||
"tube8",
|
||||
"supjav",
|
||||
"eporner",
|
||||
];
|
||||
|
||||
/// True for a `/proxy/<redirect proxy>/...` URL (absolute or host-relative).
|
||||
@@ -117,11 +115,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/eporner/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
.route(web::get().to(proxy2redirect)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/vidara/{endpoint}*")
|
||||
.route(web::post().to(proxy2redirect))
|
||||
@@ -235,6 +228,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::post().to(crate::proxies::melonstube::serve))
|
||||
.route(web::get().to(crate::proxies::melonstube::serve)),
|
||||
);
|
||||
cfg.service(
|
||||
web::resource("/eporner/{endpoint}*")
|
||||
.route(web::get().to(crate::proxies::eporner::serve_media))
|
||||
.route(web::head().to(crate::proxies::eporner::serve_media)),
|
||||
);
|
||||
cfg.service(
|
||||
web::resource("/xiaoshenke/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::xiaoshenke::serve_media))
|
||||
@@ -277,7 +275,6 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
||||
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
||||
"tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())),
|
||||
"supjav" => Some(AnyProxy::Supjav(SupjavProxy::new())),
|
||||
"eporner" => Some(AnyProxy::Eporner(EpornerProxy::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +103,11 @@ fn calc_hash(hash: &str) -> Option<String> {
|
||||
/// lack thereof) can't accidentally trip this check.
|
||||
///
|
||||
/// The signed CDN URLs also expire two hours after this call (the leading
|
||||
/// number in `/{expiry}_{ip}_{n}/` is that deadline; the IP is not enforced)
|
||||
/// and are then redirected to the same `na.mp4` decoy. Anything handed to a
|
||||
/// client should therefore be a [`resolve_proxied_formats`] URL, which
|
||||
/// resolves afresh at play time. Returns `(height, format)`, best first.
|
||||
/// number in `/{expiry}_{ip}_{n}/` is that deadline) and only serve the IP
|
||||
/// embedded there; anything else gets the same `na.mp4` decoy.
|
||||
/// Anything handed to a client should therefore be a
|
||||
/// [`resolve_proxied_formats`] URL, streamed through this server.
|
||||
/// Returns `(height, format)`, best first.
|
||||
pub async fn resolve_ranked(
|
||||
requester: &mut Requester,
|
||||
alnum_id: &str,
|
||||
@@ -161,8 +162,8 @@ pub async fn resolve_ranked(
|
||||
}
|
||||
|
||||
/// Resolves the qualities a video offers, but points each one at the
|
||||
/// `/proxy/eporner/{alnum_id}/{height}` redirect proxy instead of the
|
||||
/// two-hour CDN URL, so playback never starts from an expired link.
|
||||
/// `/proxy/eporner/{alnum_id}/{height}.mp4` streaming proxy: the CDN URLs
|
||||
/// expire after two hours and only serve the IP that resolved them.
|
||||
pub async fn resolve_proxied_formats(
|
||||
requester: &mut Requester,
|
||||
options: &ServerOptions,
|
||||
@@ -185,7 +186,7 @@ fn proxied_formats(
|
||||
ranked
|
||||
.into_iter()
|
||||
.map(|(height, mut format)| {
|
||||
format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}");
|
||||
format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}.mp4");
|
||||
format
|
||||
})
|
||||
.collect()
|
||||
@@ -196,14 +197,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proxied_formats_point_at_the_redirect_proxy() {
|
||||
fn proxied_formats_point_at_the_streaming_proxy() {
|
||||
let ranked = vec![(
|
||||
720,
|
||||
VideoFormat::new("https://vid.cdn/x-720p.mp4".into(), "720p".into(), "mp4".into()),
|
||||
)];
|
||||
let formats = proxied_formats("https://hottub.example/", "exfeTXHuL1x", ranked);
|
||||
assert_eq!(formats[0].url, "https://hottub.example/proxy/eporner/exfeTXHuL1x/720");
|
||||
assert!(crate::proxy::is_redirect_proxy_url(&formats[0].url));
|
||||
assert_eq!(formats[0].url, "https://hottub.example/proxy/eporner/exfeTXHuL1x/720.mp4");
|
||||
// Streamed, not redirected: app clients must keep it in `formats`.
|
||||
assert!(!crate::proxy::is_redirect_proxy_url(&formats[0].url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user