From 6ba65f14575389d3f21d88249660ab784c9b01fd Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 22 Oct 2026 18:09:23 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi --- docs/provider-catalog.md | 3 +- src/providers/eporner.rs | 21 +++++++++--- src/providers/melonstube.rs | 15 +++++++-- src/proxies/eporner.rs | 65 +++++++++++++++++++++++++++++++++++++ src/proxies/mod.rs | 4 +++ src/proxy.rs | 8 +++++ src/util/eporner_resolve.rs | 58 ++++++++++++++++++++++++++------- 7 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 src/proxies/eporner.rs diff --git a/docs/provider-catalog.md b/docs/provider-catalog.md index b5080a4..c0ed55c 100644 --- a/docs/provider-catalog.md +++ b/docs/provider-catalog.md @@ -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=…` ``, 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 | 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=` 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=` 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. | | `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,6 +116,7 @@ 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) diff --git a/src/providers/eporner.rs b/src/providers/eporner.rs index 60c6ec5..26cb519 100644 --- a/src/providers/eporner.rs +++ b/src/providers/eporner.rs @@ -440,15 +440,25 @@ impl EpornerProvider { 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 // `crate::util::eporner_resolve` (shared with other providers that // 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. - let formats = - crate::util::eporner_resolve::resolve_formats(requester, &card.alnum_id, &card.page_url) - .await; + // Formats point at the eporner redirect proxy: the CDN links expire two + // hours after resolving and then play a "watch on eporner.com" clip. + let formats = crate::util::eporner_resolve::resolve_proxied_formats( + requester, + options, + &card.alnum_id, + &card.page_url, + ) + .await; let mut item = VideoItem::new( card.id, @@ -648,7 +658,8 @@ impl EpornerProvider { let cards = Self::parse_list_cards(&html)?; let items = stream::iter(cards.into_iter().take(per_page.max(1)).map(|card| { 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) .collect::>() diff --git a/src/providers/melonstube.rs b/src/providers/melonstube.rs index 04a6b92..9051af0 100644 --- a/src/providers/melonstube.rs +++ b/src/providers/melonstube.rs @@ -494,11 +494,18 @@ 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. async fn resolve_eporner( requester: &mut Requester, + options: &ServerOptions, detail_url: &str, ) -> Option> { - 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 { @@ -632,10 +639,11 @@ impl MelonstubeProvider { async fn resolve_source_formats( kind: SourceKind, requester: &mut Requester, + options: &ServerOptions, detail_url: &str, ) -> Option> { 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::KvsCfGated => Self::resolve_kvs(requester, detail_url, true).await, SourceKind::XhamsterMirror => Self::resolve_xhamster_mirror(requester, detail_url).await, @@ -904,6 +912,7 @@ impl MelonstubeProvider { if !pending.is_empty() { let resolved = stream::iter(pending.into_iter().map(|(idx, kind, dest_url)| { let mut req = requester.clone(); + let options = &options; async move { // Bounded so one slow/hung third-party destination (e.g. // an unreachable FlareSolverr fallback) can't stall the @@ -911,7 +920,7 @@ impl MelonstubeProvider { // its plain destination URL with no formats. let formats = tokio::time::timeout( 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 .ok() diff --git a/src/proxies/eporner.rs b/src/proxies/eporner.rs new file mode 100644 index 0000000..5233ef9 --- /dev/null +++ b/src/proxies/eporner.rs @@ -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)> { + 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::().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, + ) -> 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); + } +} diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 8f819ac..4f2b2f8 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -1,6 +1,7 @@ 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; @@ -24,6 +25,7 @@ pub mod animeidhentai; pub mod archivebate; pub mod clapdat; pub mod doodstream; +pub mod eporner; pub mod fikfapthumb; pub mod hanimecdn; pub mod hanimethumb; @@ -71,6 +73,7 @@ pub enum AnyProxy { ThaipornTv(ThaipornTvProxy), Tube8(Tube8Proxy), Supjav(SupjavProxy), + Eporner(EpornerProxy), } pub trait Proxy { @@ -97,6 +100,7 @@ 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, } } } diff --git a/src/proxy.rs b/src/proxy.rs index 80d2aa7..0ba439c 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -3,6 +3,7 @@ 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; @@ -43,6 +44,7 @@ pub const REDIRECT_PROXIES: &[&str] = &[ "thaiporntv", "tube8", "supjav", + "eporner", ]; /// True for a `/proxy//...` URL (absolute or host-relative). @@ -115,6 +117,11 @@ 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)) @@ -270,6 +277,7 @@ fn get_proxy(proxy: &str) -> Option { "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, } } diff --git a/src/util/eporner_resolve.rs b/src/util/eporner_resolve.rs index b16d283..456833a 100644 --- a/src/util/eporner_resolve.rs +++ b/src/util/eporner_resolve.rs @@ -12,7 +12,7 @@ //! always compiled regardless of provider selection. use crate::util::requester::Requester; -use crate::videos::VideoFormat; +use crate::videos::{ServerOptions, VideoFormat}; use regex::Regex; use std::sync::OnceLock; use wreq::Version; @@ -101,11 +101,17 @@ fn calc_hash(hash: &str) -> Option { /// when playback doesn't carry the header the CDN wants. Each format /// therefore declares an explicit `Referer` so the client's own default (or /// 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, alnum_id: &str, page_url: &str, -) -> Option> { +) -> Option> { let detail_html = requester .get_with_headers(page_url, html_headers(page_url), Some(Version::HTTP_11)) .await @@ -151,25 +157,55 @@ pub async fn resolve_formats( return None; } 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 -/// page URL, extracting the alnum id from the URL itself. For use by -/// providers that only have a raw destination URL (e.g. a meta-search -/// aggregator's out-link), not a pre-parsed listing card. -pub async fn resolve_public_formats( +/// 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. +pub async fn resolve_proxied_formats( requester: &mut Requester, + options: &ServerOptions, + alnum_id: &str, page_url: &str, ) -> Option> { - let alnum_id = alnum_id_from_href(page_url)?; - resolve_formats(requester, &alnum_id, page_url).await + let ranked = resolve_ranked(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 { + 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)] mod tests { 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] fn calc_hash_matches_site_algorithm() { // Reverse-engineered from vjs.js; verified against a live page/xhr pair.