eporner: play through a redirect proxy; the signed CDN links expire after 2h
Expired eporner mp4 links are answered with a 302 to static.eporner.com/
na.mp4, a one-minute "watch it on eporner.com" clip. The links expire
exactly two hours after the /xhr/video call, so anything played from a
favourite, history entry or a list left open past that hit the decoy.
New /proxy/eporner/{alnum_id}/{height} re-resolves the hash/xhr chain at
play time and 302s to the matching (else best) quality. The eporner
provider and melonstube's eporner destinations now emit these proxy URLs;
verified app clients get the best quality as video.url via the existing
redirect-proxy rule. Verified live: proxy -> fresh CDN link -> 206 video/mp4
for app and non-app clients.
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. |
|
| `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. |
|
| `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. |
|
| `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 | no | 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(...)`. |
|
| `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`. |
|
||||||
| `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. |
|
| `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. |
|
| `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. |
|
| `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,6 +116,7 @@ These resolve a provider-specific input into a `302 Location`.
|
|||||||
- `/proxy/allpornstream/{endpoint}*`
|
- `/proxy/allpornstream/{endpoint}*`
|
||||||
- `/proxy/tube8/{endpoint}*`
|
- `/proxy/tube8/{endpoint}*`
|
||||||
- `/proxy/supjav/{endpoint}*`
|
- `/proxy/supjav/{endpoint}*`
|
||||||
|
- `/proxy/eporner/{alnum_id}/{height}` (fresh signed mp4 per play; the CDN links expire after 2h)
|
||||||
- `/proxy/jable/{slug}*`
|
- `/proxy/jable/{slug}*`
|
||||||
- `/proxy/thepornbunny/{slug}*`
|
- `/proxy/thepornbunny/{slug}*`
|
||||||
- `/proxy/animeidhentai/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL)
|
- `/proxy/animeidhentai/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL)
|
||||||
|
|||||||
@@ -440,15 +440,25 @@ impl EpornerProvider {
|
|||||||
Ok(cards)
|
Ok(cards)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_item(requester: &mut Requester, card: EpornerCard) -> VideoItem {
|
async fn build_item(
|
||||||
|
requester: &mut Requester,
|
||||||
|
options: &ServerOptions,
|
||||||
|
card: EpornerCard,
|
||||||
|
) -> VideoItem {
|
||||||
// The hash/xhr resolution + Referer handling live in
|
// The hash/xhr resolution + Referer handling live in
|
||||||
// `crate::util::eporner_resolve` (shared with other providers that
|
// `crate::util::eporner_resolve` (shared with other providers that
|
||||||
// redirect out to eporner.com, e.g. `melonstube`) rather than here,
|
// redirect out to eporner.com, e.g. `melonstube`) rather than here,
|
||||||
// since single-provider builds can't depend on another provider's
|
// since single-provider builds can't depend on another provider's
|
||||||
// module — see that module's doc comment.
|
// module — see that module's doc comment.
|
||||||
let formats =
|
// Formats point at the eporner redirect proxy: the CDN links expire two
|
||||||
crate::util::eporner_resolve::resolve_formats(requester, &card.alnum_id, &card.page_url)
|
// hours after resolving and then play a "watch on eporner.com" clip.
|
||||||
.await;
|
let formats = crate::util::eporner_resolve::resolve_proxied_formats(
|
||||||
|
requester,
|
||||||
|
options,
|
||||||
|
&card.alnum_id,
|
||||||
|
&card.page_url,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut item = VideoItem::new(
|
let mut item = VideoItem::new(
|
||||||
card.id,
|
card.id,
|
||||||
@@ -648,7 +658,8 @@ impl EpornerProvider {
|
|||||||
let cards = Self::parse_list_cards(&html)?;
|
let cards = Self::parse_list_cards(&html)?;
|
||||||
let items = stream::iter(cards.into_iter().take(per_page.max(1)).map(|card| {
|
let items = stream::iter(cards.into_iter().take(per_page.max(1)).map(|card| {
|
||||||
let mut req = requester.clone();
|
let mut req = requester.clone();
|
||||||
async move { Self::build_item(&mut req, card).await }
|
let options = &options;
|
||||||
|
async move { Self::build_item(&mut req, options, card).await }
|
||||||
}))
|
}))
|
||||||
.buffer_unordered(ENRICH_CONCURRENCY)
|
.buffer_unordered(ENRICH_CONCURRENCY)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|||||||
@@ -494,11 +494,18 @@ impl MelonstubeProvider {
|
|||||||
Self::parse_kvs_formats(&html, &origin)
|
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.
|
||||||
async fn resolve_eporner(
|
async fn resolve_eporner(
|
||||||
requester: &mut Requester,
|
requester: &mut Requester,
|
||||||
|
options: &ServerOptions,
|
||||||
detail_url: &str,
|
detail_url: &str,
|
||||||
) -> Option<Vec<VideoFormat>> {
|
) -> Option<Vec<VideoFormat>> {
|
||||||
crate::util::eporner_resolve::resolve_public_formats(requester, detail_url).await
|
let alnum_id = crate::util::eporner_resolve::alnum_id_from_href(detail_url)?;
|
||||||
|
crate::util::eporner_resolve::resolve_proxied_formats(
|
||||||
|
requester, options, &alnum_id, detail_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xhamster_mirror_mp4_regex() -> &'static Regex {
|
fn xhamster_mirror_mp4_regex() -> &'static Regex {
|
||||||
@@ -632,10 +639,11 @@ impl MelonstubeProvider {
|
|||||||
async fn resolve_source_formats(
|
async fn resolve_source_formats(
|
||||||
kind: SourceKind,
|
kind: SourceKind,
|
||||||
requester: &mut Requester,
|
requester: &mut Requester,
|
||||||
|
options: &ServerOptions,
|
||||||
detail_url: &str,
|
detail_url: &str,
|
||||||
) -> Option<Vec<VideoFormat>> {
|
) -> Option<Vec<VideoFormat>> {
|
||||||
match kind {
|
match kind {
|
||||||
SourceKind::Eporner => Self::resolve_eporner(requester, detail_url).await,
|
SourceKind::Eporner => Self::resolve_eporner(requester, options, detail_url).await,
|
||||||
SourceKind::Kvs => Self::resolve_kvs(requester, detail_url, false).await,
|
SourceKind::Kvs => Self::resolve_kvs(requester, detail_url, false).await,
|
||||||
SourceKind::KvsCfGated => Self::resolve_kvs(requester, detail_url, true).await,
|
SourceKind::KvsCfGated => Self::resolve_kvs(requester, detail_url, true).await,
|
||||||
SourceKind::XhamsterMirror => Self::resolve_xhamster_mirror(requester, detail_url).await,
|
SourceKind::XhamsterMirror => Self::resolve_xhamster_mirror(requester, detail_url).await,
|
||||||
@@ -904,6 +912,7 @@ impl MelonstubeProvider {
|
|||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
let resolved = stream::iter(pending.into_iter().map(|(idx, kind, dest_url)| {
|
let resolved = stream::iter(pending.into_iter().map(|(idx, kind, dest_url)| {
|
||||||
let mut req = requester.clone();
|
let mut req = requester.clone();
|
||||||
|
let options = &options;
|
||||||
async move {
|
async move {
|
||||||
// Bounded so one slow/hung third-party destination (e.g.
|
// Bounded so one slow/hung third-party destination (e.g.
|
||||||
// an unreachable FlareSolverr fallback) can't stall the
|
// an unreachable FlareSolverr fallback) can't stall the
|
||||||
@@ -911,7 +920,7 @@ impl MelonstubeProvider {
|
|||||||
// its plain destination URL with no formats.
|
// its plain destination URL with no formats.
|
||||||
let formats = tokio::time::timeout(
|
let formats = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(ENRICH_TIMEOUT_SECS),
|
std::time::Duration::from_secs(ENRICH_TIMEOUT_SECS),
|
||||||
Self::resolve_source_formats(kind, &mut req, &dest_url),
|
Self::resolve_source_formats(kind, &mut req, options, &dest_url),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
65
src/proxies/eporner.rs
Normal file
65
src/proxies/eporner.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use ntex::web;
|
||||||
|
|
||||||
|
use crate::util::eporner_resolve::resolve_ranked;
|
||||||
|
use crate::util::requester::Requester;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EpornerProxy {}
|
||||||
|
|
||||||
|
impl EpornerProxy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
EpornerProxy {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{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())
|
||||||
|
})?;
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
height
|
||||||
|
.and_then(|h| ranked.iter().find(|(height, _)| *height == h))
|
||||||
|
.or_else(|| ranked.first())
|
||||||
|
.map(|(_, format)| format.url.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::EpornerProxy;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_id_and_optional_height() {
|
||||||
|
assert_eq!(
|
||||||
|
EpornerProxy::parse_endpoint("exfeTXHuL1x/720"),
|
||||||
|
Some(("exfeTXHuL1x".to_string(), Some(720)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EpornerProxy::parse_endpoint("/exfeTXHuL1x/"),
|
||||||
|
Some(("exfeTXHuL1x".to_string(), None))
|
||||||
|
);
|
||||||
|
assert_eq!(EpornerProxy::parse_endpoint("../etc"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::proxies::archivebate::ArchivebateProxy;
|
use crate::proxies::archivebate::ArchivebateProxy;
|
||||||
use crate::proxies::clapdat::ClapdatProxy;
|
use crate::proxies::clapdat::ClapdatProxy;
|
||||||
use crate::proxies::doodstream::DoodstreamProxy;
|
use crate::proxies::doodstream::DoodstreamProxy;
|
||||||
|
use crate::proxies::eporner::EpornerProxy;
|
||||||
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
||||||
use crate::proxies::hqporner::HqpornerProxy;
|
use crate::proxies::hqporner::HqpornerProxy;
|
||||||
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
use crate::proxies::pornhd3x::Pornhd3xProxy;
|
||||||
@@ -24,6 +25,7 @@ pub mod animeidhentai;
|
|||||||
pub mod archivebate;
|
pub mod archivebate;
|
||||||
pub mod clapdat;
|
pub mod clapdat;
|
||||||
pub mod doodstream;
|
pub mod doodstream;
|
||||||
|
pub mod eporner;
|
||||||
pub mod fikfapthumb;
|
pub mod fikfapthumb;
|
||||||
pub mod hanimecdn;
|
pub mod hanimecdn;
|
||||||
pub mod hanimethumb;
|
pub mod hanimethumb;
|
||||||
@@ -71,6 +73,7 @@ pub enum AnyProxy {
|
|||||||
ThaipornTv(ThaipornTvProxy),
|
ThaipornTv(ThaipornTvProxy),
|
||||||
Tube8(Tube8Proxy),
|
Tube8(Tube8Proxy),
|
||||||
Supjav(SupjavProxy),
|
Supjav(SupjavProxy),
|
||||||
|
Eporner(EpornerProxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Proxy {
|
pub trait Proxy {
|
||||||
@@ -97,6 +100,7 @@ impl Proxy for AnyProxy {
|
|||||||
AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await,
|
AnyProxy::ThaipornTv(p) => p.get_video_url(url, requester).await,
|
||||||
AnyProxy::Tube8(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::Supjav(p) => p.get_video_url(url, requester).await,
|
||||||
|
AnyProxy::Eporner(p) => p.get_video_url(url, requester).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use ntex::web::{self, HttpRequest};
|
|||||||
use crate::proxies::archivebate::ArchivebateProxy;
|
use crate::proxies::archivebate::ArchivebateProxy;
|
||||||
use crate::proxies::clapdat::ClapdatProxy;
|
use crate::proxies::clapdat::ClapdatProxy;
|
||||||
use crate::proxies::doodstream::DoodstreamProxy;
|
use crate::proxies::doodstream::DoodstreamProxy;
|
||||||
|
use crate::proxies::eporner::EpornerProxy;
|
||||||
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
use crate::proxies::heavyfetish::HeavyfetishProxy;
|
||||||
use crate::proxies::hqporner::HqpornerProxy;
|
use crate::proxies::hqporner::HqpornerProxy;
|
||||||
use crate::proxies::javtiful::JavtifulProxy;
|
use crate::proxies::javtiful::JavtifulProxy;
|
||||||
@@ -43,6 +44,7 @@ pub const REDIRECT_PROXIES: &[&str] = &[
|
|||||||
"thaiporntv",
|
"thaiporntv",
|
||||||
"tube8",
|
"tube8",
|
||||||
"supjav",
|
"supjav",
|
||||||
|
"eporner",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// True for a `/proxy/<redirect proxy>/...` URL (absolute or host-relative).
|
/// True for a `/proxy/<redirect proxy>/...` URL (absolute or host-relative).
|
||||||
@@ -115,6 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(proxy2redirect))
|
.route(web::post().to(proxy2redirect))
|
||||||
.route(web::get().to(proxy2redirect)),
|
.route(web::get().to(proxy2redirect)),
|
||||||
)
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/eporner/{endpoint}*")
|
||||||
|
.route(web::post().to(proxy2redirect))
|
||||||
|
.route(web::get().to(proxy2redirect)),
|
||||||
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/vidara/{endpoint}*")
|
web::resource("/vidara/{endpoint}*")
|
||||||
.route(web::post().to(proxy2redirect))
|
.route(web::post().to(proxy2redirect))
|
||||||
@@ -270,6 +277,7 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
|||||||
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
"thaiporntv" => Some(AnyProxy::ThaipornTv(ThaipornTvProxy::new())),
|
||||||
"tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())),
|
"tube8" => Some(AnyProxy::Tube8(Tube8Proxy::new())),
|
||||||
"supjav" => Some(AnyProxy::Supjav(SupjavProxy::new())),
|
"supjav" => Some(AnyProxy::Supjav(SupjavProxy::new())),
|
||||||
|
"eporner" => Some(AnyProxy::Eporner(EpornerProxy::new())),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! always compiled regardless of provider selection.
|
//! always compiled regardless of provider selection.
|
||||||
|
|
||||||
use crate::util::requester::Requester;
|
use crate::util::requester::Requester;
|
||||||
use crate::videos::VideoFormat;
|
use crate::videos::{ServerOptions, VideoFormat};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use wreq::Version;
|
use wreq::Version;
|
||||||
@@ -101,11 +101,17 @@ fn calc_hash(hash: &str) -> Option<String> {
|
|||||||
/// when playback doesn't carry the header the CDN wants. Each format
|
/// when playback doesn't carry the header the CDN wants. Each format
|
||||||
/// therefore declares an explicit `Referer` so the client's own default (or
|
/// therefore declares an explicit `Referer` so the client's own default (or
|
||||||
/// lack thereof) can't accidentally trip this check.
|
/// lack thereof) can't accidentally trip this check.
|
||||||
pub async fn resolve_formats(
|
///
|
||||||
|
/// 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.
|
||||||
|
pub async fn resolve_ranked(
|
||||||
requester: &mut Requester,
|
requester: &mut Requester,
|
||||||
alnum_id: &str,
|
alnum_id: &str,
|
||||||
page_url: &str,
|
page_url: &str,
|
||||||
) -> Option<Vec<VideoFormat>> {
|
) -> Option<Vec<(u32, VideoFormat)>> {
|
||||||
let detail_html = requester
|
let detail_html = requester
|
||||||
.get_with_headers(page_url, html_headers(page_url), Some(Version::HTTP_11))
|
.get_with_headers(page_url, html_headers(page_url), Some(Version::HTTP_11))
|
||||||
.await
|
.await
|
||||||
@@ -151,25 +157,55 @@ pub async fn resolve_formats(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
Some(ranked.into_iter().map(|(_, f)| f).collect())
|
Some(ranked)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves eporner.com direct mp4 formats for an arbitrary eporner detail
|
/// Resolves the qualities a video offers, but points each one at the
|
||||||
/// page URL, extracting the alnum id from the URL itself. For use by
|
/// `/proxy/eporner/{alnum_id}/{height}` redirect proxy instead of the
|
||||||
/// providers that only have a raw destination URL (e.g. a meta-search
|
/// two-hour CDN URL, so playback never starts from an expired link.
|
||||||
/// aggregator's out-link), not a pre-parsed listing card.
|
pub async fn resolve_proxied_formats(
|
||||||
pub async fn resolve_public_formats(
|
|
||||||
requester: &mut Requester,
|
requester: &mut Requester,
|
||||||
|
options: &ServerOptions,
|
||||||
|
alnum_id: &str,
|
||||||
page_url: &str,
|
page_url: &str,
|
||||||
) -> Option<Vec<VideoFormat>> {
|
) -> Option<Vec<VideoFormat>> {
|
||||||
let alnum_id = alnum_id_from_href(page_url)?;
|
let ranked = resolve_ranked(requester, alnum_id, page_url).await?;
|
||||||
resolve_formats(requester, &alnum_id, page_url).await
|
let base = options.public_url_base.as_deref().unwrap_or("");
|
||||||
|
Some(proxied_formats(base, alnum_id, ranked))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the URLs like `providers::build_proxy_url`, which this always
|
||||||
|
/// compiled module cannot rely on in single-provider builds.
|
||||||
|
fn proxied_formats(
|
||||||
|
public_url_base: &str,
|
||||||
|
alnum_id: &str,
|
||||||
|
ranked: Vec<(u32, VideoFormat)>,
|
||||||
|
) -> Vec<VideoFormat> {
|
||||||
|
let base = public_url_base.trim_end_matches('/');
|
||||||
|
ranked
|
||||||
|
.into_iter()
|
||||||
|
.map(|(height, mut format)| {
|
||||||
|
format.url = format!("{base}/proxy/eporner/{alnum_id}/{height}");
|
||||||
|
format
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxied_formats_point_at_the_redirect_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));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn calc_hash_matches_site_algorithm() {
|
fn calc_hash_matches_site_algorithm() {
|
||||||
// Reverse-engineered from vjs.js; verified against a live page/xhr pair.
|
// Reverse-engineered from vjs.js; verified against a live page/xhr pair.
|
||||||
|
|||||||
Reference in New Issue
Block a user