hentai
This commit is contained in:
10
build.rs
10
build.rs
@@ -16,6 +16,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
||||
module: "pornxp",
|
||||
ty: "PornxpProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "animeidhentai",
|
||||
module: "animeidhentai",
|
||||
ty: "AnimeidhentaiProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "all",
|
||||
module: "all",
|
||||
@@ -371,6 +376,11 @@ const PROVIDERS: &[ProviderDef] = &[
|
||||
module: "fyptt",
|
||||
ty: "FypttProvider",
|
||||
},
|
||||
ProviderDef {
|
||||
id: "hentaitv",
|
||||
module: "hentaitv",
|
||||
ty: "HentaitvProvider",
|
||||
},
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
@@ -8,6 +8,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `all` | `meta-search` | no | no | Aggregates all compiled providers. |
|
||||
| `allpornstream` | `mainstream-tube` | no | yes | Next.js App Router scraper; extracts cards via `data-thumb-id/href/title/images` attributes; redirect proxy lazy-resolves VOE/DoodStream/StreamTape/FileMoon embeds. |
|
||||
| `animeidhentai` | `hentai-animation` | no | yes | Next.js hentai site (animeidhentai.com) backed by a clean JSON API: latest feed `GET /api/browse?page=N` (`{videos:[28],total,pages}`, real pagination, ignores sort/genre params) and search `GET /api/search?q=Q&page=N` (`{videos:[8]}`, matches titles AND tags). Each episode JSON carries `slug`/`titleSlug`/`ep`, `title`, `tags[]`, `views`, `rating` (0-10 → ×10 for the 0-100 scale), `duration` ("MM:SS"), `brand` (studio → `uploader`), `releasedAt` (RFC3339 → `uploadedAt`), relative `thumb`/`backdrop` images (served from `animeidhentai.com/uploads/...`, no referer), and an `embedUrl` of the form `https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable series page `https://animeidhentai.com/series/{titleSlug}` (the per-episode watch route 307-redirects to `/`; episodes are watched on the series page). `genre:`/`tag:`/`cat:`/`category:` query prefixes and the `categories` filter (curated genre list, sanitized out of `/api/status` but honored in `/api/videos`) route to `/api/search` since browse can't filter by genre. Playback: yt-dlp cannot resolve nhplayer, whose real MP4 sits on a Cloudflare-fronted R2 bucket (`r2.1hanime.com`) behind a signed `?verify=<ts>-<sig>` token minted by an obfuscated JS challenge (`player.php` → `player-core-v2.php` → `get-video-url-v2.php`): a SHA-256 proof-of-work over five DOM-embedded parts + a fixed fingerprint + a ≥700ms server-enforced dwell time, all replicated in `src/proxies/animeidhentai.rs`. The signed URL further needs a *browser* TLS JA3 to clear Cloudflare — curl_cffi/AVFoundation pass but our `wreq` stack is JA3-blocked on every emulation profile — so the proxy cannot stream server-side. `/proxy/animeidhentai/{embedId}.mp4` is therefore a **redirect** proxy (like `jable`): HEAD→200 (so health checks/yt-dlp media detection pass on the `.mp4` extension), GET→302 to the freshly-resolved CDN URL, which the client fetches directly (yt-dlp resolves it with `--impersonate chrome`). Resolved URLs are cached 150s. No `/api/uploaders` (no stable uploader identity; `brand` is studio-only). |
|
||||
| `archivebate` | `live-cams` | no | no | Livewire-backed cam archive listings with platform/gender/profile shortcuts. |
|
||||
| `beeg` | `mainstream-tube` | no | no | Basic mainstream tube pattern. |
|
||||
| `blowjobspro` | `mainstream-tube` | no | no | KVS-style HTML provider with async search pagination and category shortcut routing. |
|
||||
@@ -21,6 +22,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
|
||||
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
|
||||
| `heavyfetish` | `fetish-kink` | no | no | Direct media handling. |
|
||||
| `hentaihaven` | `hentai-animation` | no | no | HLS format builder pattern. |
|
||||
| `hentaitv` | `hentai-animation` | no | yes | Next.js hentai site (hentai.tv) backed by a clean JSON API: `GET /api/browse?page=N&sort=<Label>&genres=<ExactName>` (`{videos:[28],total,pages}`, real pagination) and `GET /api/search?q=Q` (`{videos:[...]}`, single-page — `page` is ignored, so page>1 returns empty). Unlike `animeidhentai`, browse honors both `sort` (labels `Most Recent`/`Most Viewed`/`Trending`, mapped from option ids `new`/`views`/`trending`) and `genres` (the **exact case-sensitive** stored genre name, e.g. `Big Boobs`, `incest`), so genre archives go through `/api/browse?genres=` and paginate. The 68-genre catalogue (exact names) is background-loaded from the `/browse` page HTML (`"genres":[{"name","count"}]`, not exposed by the JSON API) and powers the `categories` filter plus keyword→genre routing. Each episode JSON has `slug`, `title`/`ep`, `tags[]`, `views`, `rating` (0-10 → ×10), `duration` ("MM:SS"), `brand` (studio → `uploader`), `thumb`/`backdrop`/`cover` (relative, served from `hentai.tv/uploads/...`, no referer), and `embedUrl=https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable watch page `https://hentai.tv/hentai/{slug}`; `genre:`/`cat:`/`category:` prefixes and bare keywords that exactly match a genre route to the genre archive, everything else to search. Playback shares the **same nhplayer→`r2.1hanime.com` signed-CDN backend as `animeidhentai`**: `/proxy/hentaitv/{embedId}.mp4` is a redirect proxy that replicates nhplayer's PoW+DOM challenge (`player.php`→`player-core-v2.php`→`get-video-url-v2.php`, SHA-256-first-byte-zero PoW, ≥700ms dwell, fixed fingerprint) to mint a signed `?verify=<ts>-<sig>` URL — HEAD→200, GET→302 to the CDN URL (cached 150s). The CF wall is JA3-based not IP-based, so the signed URL is verifiable from anywhere with `yt-dlp --impersonate chrome` even though plain `curl`/`wreq` get 403. `src/proxies/hentaitv.rs` is a near-copy of `src/proxies/animeidhentai.rs` (only `SITE_REFERER` differs). No `/api/uploaders` (brand is studio-only). |
|
||||
| `homoxxx` | `gay-male` | no | no | Gay category grouping example. |
|
||||
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
|
||||
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
|
||||
@@ -99,6 +101,8 @@ These resolve a provider-specific input into a `302 Location`.
|
||||
- `/proxy/supjav/{endpoint}*`
|
||||
- `/proxy/jable/{slug}*`
|
||||
- `/proxy/thepornbunny/{slug}*`
|
||||
- `/proxy/animeidhentai/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL)
|
||||
- `/proxy/hentaitv/{embedId}.mp4` (HEAD→200, GET→302 to the signed `r2.1hanime.com` CDN URL; same nhplayer challenge as `animeidhentai`)
|
||||
|
||||
### Media/image proxies
|
||||
|
||||
|
||||
361
src/providers/animeidhentai.rs
Normal file
361
src/providers/animeidhentai.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
// animeidhentai.com — subbed/uncensored hentai episodes.
|
||||
//
|
||||
// The site is a Next.js app backed by a clean JSON API:
|
||||
// GET /api/browse?page=N -> {videos:[28], total, pages} (latest releases, paginated)
|
||||
// GET /api/search?q=Q&page=N -> {videos:[8]} (matches titles AND tags)
|
||||
// `browse` ignores sort/genre params, so genre/tag shortcuts route through `search` (which
|
||||
// matches tag names well). Episode JSON carries everything we need for a card (title, tags,
|
||||
// views, rating, duration, studio, release date) plus an `embedUrl` of the form
|
||||
// `https://nhplayer.com/v/{embedId}/`.
|
||||
//
|
||||
// Playback: the watch page is client-rendered and yt-dlp cannot resolve nhplayer or the
|
||||
// Cloudflare-fronted CDN behind it, so `video.url` is the (reachable) series page and the real
|
||||
// MP4 is served through `/proxy/animeidhentai/{embedId}.mp4`, which replicates nhplayer's
|
||||
// browser challenge and streams the signed CDN bytes (see src/proxies/animeidhentai.rs).
|
||||
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{
|
||||
Provider, build_proxy_url, report_provider_error, requester_or_default,
|
||||
};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::requester::Requester;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "hentai-animation",
|
||||
tags: &["hentai", "anime", "subbed", "uncensored", "hd"],
|
||||
};
|
||||
|
||||
const CHANNEL_ID: &str = "animeidhentai";
|
||||
const BASE_URL: &str = "https://animeidhentai.com";
|
||||
const SITE_REFERER: &str = "https://animeidhentai.com/";
|
||||
|
||||
// Curated popular genres for the `categories` filter. The id doubles as the search term, since
|
||||
// genre filtering is implemented by routing to `/api/search?q=<id>`.
|
||||
const GENRES: &[&str] = &[
|
||||
"ahegao",
|
||||
"anal",
|
||||
"big breasts",
|
||||
"blowjob",
|
||||
"bondage",
|
||||
"censored",
|
||||
"creampie",
|
||||
"dark skin",
|
||||
"demon",
|
||||
"elf",
|
||||
"footjob",
|
||||
"futanari",
|
||||
"gangbang",
|
||||
"harem",
|
||||
"incest",
|
||||
"milf",
|
||||
"nakadashi",
|
||||
"netorare",
|
||||
"nurse",
|
||||
"paizuri",
|
||||
"schoolgirl",
|
||||
"teacher",
|
||||
"tentacle",
|
||||
"threesome",
|
||||
"toys",
|
||||
"uncensored",
|
||||
"virgin",
|
||||
"yuri",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Target {
|
||||
Latest,
|
||||
Search(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimeidhentaiProvider {}
|
||||
|
||||
impl AnimeidhentaiProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
s.split_whitespace()
|
||||
.map(|w| {
|
||||
let mut chars = w.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_channel(&self, _cv: ClientVersion) -> Channel {
|
||||
let mut cat_options: Vec<FilterOption> = vec![FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
}];
|
||||
for genre in GENRES {
|
||||
cat_options.push(FilterOption {
|
||||
id: genre.to_string(),
|
||||
title: Self::title_case(genre),
|
||||
});
|
||||
}
|
||||
|
||||
Channel {
|
||||
id: CHANNEL_ID.to_string(),
|
||||
name: "AnimeIDHentai".to_string(),
|
||||
description:
|
||||
"AnimeIDHentai — latest subbed and uncensored hentai episodes with keyword and genre search."
|
||||
.to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=animeidhentai.com".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: GENRES.iter().map(|g| Self::title_case(g)).collect(),
|
||||
options: vec![ChannelOption {
|
||||
id: "categories".to_string(),
|
||||
title: "Genres".to_string(),
|
||||
description: "Browse a hentai genre.".to_string(),
|
||||
systemImage: "square.grid.2x2".to_string(),
|
||||
colorName: "pink".to_string(),
|
||||
options: cat_options,
|
||||
multiSelect: false,
|
||||
}],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a query (with optional `genre:`/`tag:`/`cat:` prefix) onto a fetch target.
|
||||
fn resolve_query_target(query: &str) -> Target {
|
||||
let trimmed = query.trim().trim_start_matches('#').trim();
|
||||
if let Some((kind, value)) = trimmed.split_once(':') {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"genre" | "tag" | "cat" | "category" => {
|
||||
return Target::Search(value.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Search(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn target_url(target: &Target, page: u32) -> String {
|
||||
match target {
|
||||
Target::Latest => format!("{BASE_URL}/api/browse?page={page}"),
|
||||
Target::Search(q) => {
|
||||
let encoded = percent_encoding::utf8_percent_encode(
|
||||
q,
|
||||
percent_encoding::NON_ALPHANUMERIC,
|
||||
)
|
||||
.to_string();
|
||||
format!("{BASE_URL}/api/search?q={encoded}&page={page}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn abs_url(path: &str) -> String {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
path.to_string()
|
||||
} else if path.starts_with('/') {
|
||||
format!("{BASE_URL}{path}")
|
||||
} else {
|
||||
format!("{BASE_URL}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_image(v: &Value) -> String {
|
||||
for key in ["thumb", "backdrop", "featureImage", "cover"] {
|
||||
if let Some(s) = v[key].as_str() {
|
||||
if !s.is_empty() {
|
||||
return Self::abs_url(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn extract_embed_id(embed_url: &str) -> Option<String> {
|
||||
let after = embed_url.split("/v/").nth(1)?;
|
||||
let id = after.trim_matches('/').split('/').next()?.split('?').next()?;
|
||||
(!id.is_empty()).then(|| id.to_string())
|
||||
}
|
||||
|
||||
fn build_item(v: &Value, options: &ServerOptions) -> Option<VideoItem> {
|
||||
let slug = v["slug"].as_str().filter(|s| !s.is_empty())?;
|
||||
let embed_id = Self::extract_embed_id(v["embedUrl"].as_str().unwrap_or_default())?;
|
||||
|
||||
let series_slug = v["titleSlug"].as_str().unwrap_or(slug);
|
||||
let base_title = v["title"].as_str().unwrap_or_default().trim().to_string();
|
||||
let ep = v["ep"].as_u64().unwrap_or(0);
|
||||
let title = if base_title.is_empty() {
|
||||
slug.replace('-', " ")
|
||||
} else if ep >= 1 {
|
||||
format!("{base_title} Episode {ep}")
|
||||
} else {
|
||||
base_title
|
||||
};
|
||||
|
||||
let url = format!("{BASE_URL}/series/{series_slug}");
|
||||
let thumb = Self::pick_image(v);
|
||||
let duration = v["duration"]
|
||||
.as_str()
|
||||
.and_then(parse_time_to_seconds)
|
||||
.and_then(|s| u32::try_from(s).ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
slug.to_string(),
|
||||
title,
|
||||
url,
|
||||
CHANNEL_ID.to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
);
|
||||
item = item.aspect_ratio(16.0 / 9.0);
|
||||
|
||||
if let Some(views) = v["views"].as_u64() {
|
||||
item.views = Some(views.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
if let Some(rating) = v["rating"].as_f64() {
|
||||
// Site rating is 0-10; Hot Tub clients use a 0-100 scale.
|
||||
item.rating = Some(((rating * 10.0) as f32).clamp(0.0, 100.0));
|
||||
}
|
||||
|
||||
let tags: Vec<String> = v["tags"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
item = item.tags(tags);
|
||||
|
||||
if let Some(brand) = v["brand"].as_str().filter(|s| !s.is_empty()) {
|
||||
item = item.uploader(brand.to_string());
|
||||
}
|
||||
|
||||
if let Some(released) = v["releasedAt"].as_str() {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(released) {
|
||||
let ts = dt.timestamp();
|
||||
if ts > 0 {
|
||||
item = item.uploaded_at(ts as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quality = v["quality"].as_str().unwrap_or_default().to_string();
|
||||
let proxy_url = build_proxy_url(options, CHANNEL_ID, &format!("{embed_id}.mp4"));
|
||||
let label = if quality.is_empty() {
|
||||
"mp4".to_string()
|
||||
} else {
|
||||
quality.clone()
|
||||
};
|
||||
let mut fmt = VideoFormat::new(proxy_url, label, "mp4".to_string()).ext("mp4".to_string());
|
||||
if let Some(height) = quality
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|h| *h > 0)
|
||||
{
|
||||
fmt = fmt.height(height);
|
||||
}
|
||||
item = item.formats(vec![fmt]);
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
async fn fetch_videos(requester: &mut Requester, url: &str) -> Option<Vec<Value>> {
|
||||
let resp = requester
|
||||
.get_raw_with_headers(
|
||||
url,
|
||||
vec![
|
||||
("Referer".to_string(), SITE_REFERER.to_string()),
|
||||
("Accept".to_string(), "application/json".to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = resp.text().await.ok()?;
|
||||
let json: Value = serde_json::from_str(&body).ok()?;
|
||||
json["videos"].as_array().cloned()
|
||||
}
|
||||
|
||||
async fn fetch_target(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
target: Target,
|
||||
page: u32,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let url = Self::target_url(&target, page);
|
||||
|
||||
if let Some((ts, cached)) = cache.get(&url) {
|
||||
if ts.elapsed().unwrap_or_default().as_secs() < 300 {
|
||||
return cached.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester =
|
||||
requester_or_default(&options, CHANNEL_ID, "fetch_target.missing_requester");
|
||||
let Some(videos) = Self::fetch_videos(&mut requester, &url).await else {
|
||||
report_provider_error(CHANNEL_ID, "fetch_target.request", &format!("url={url}")).await;
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let items: Vec<VideoItem> = videos
|
||||
.iter()
|
||||
.filter_map(|v| Self::build_item(v, &options))
|
||||
.collect();
|
||||
|
||||
if !items.is_empty() {
|
||||
cache.insert(url, items.clone());
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for AnimeidhentaiProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
_sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
_per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u32>().unwrap_or(1).max(1);
|
||||
|
||||
let target = match query {
|
||||
Some(q) if !q.trim().is_empty() => Self::resolve_query_target(q.trim()),
|
||||
_ => match options.categories.as_deref() {
|
||||
Some(cat) if !cat.is_empty() && cat != "all" => Target::Search(cat.to_string()),
|
||||
_ => Target::Latest,
|
||||
},
|
||||
};
|
||||
|
||||
self.fetch_target(cache, target, page, options).await
|
||||
}
|
||||
|
||||
fn get_channel(&self, cv: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(cv))
|
||||
}
|
||||
}
|
||||
567
src/providers/hentaitv.rs
Normal file
567
src/providers/hentaitv.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
// hentai.tv — large subbed/raw hentai catalogue.
|
||||
//
|
||||
// The site is a Next.js app backed by a clean JSON API (no RSC scraping needed):
|
||||
// GET /api/browse?page=N&sort=<Label>&genres=<ExactName> -> {videos:[28], total, pages}
|
||||
// GET /api/search?q=Q -> {videos:[...]}
|
||||
// `browse` honours both `sort` (labels: "Most Recent" | "Most Viewed" | "Trending") and `genres`
|
||||
// (the *exact, case-sensitive* stored genre name, e.g. "Big Boobs", "incest"), and paginates;
|
||||
// `search` is single-page (it ignores `page`). Episode JSON carries everything for a card plus an
|
||||
// `embedUrl` of the form `https://nhplayer.com/v/{embedId}/`.
|
||||
//
|
||||
// Playback: yt-dlp can resolve neither the watch page nor nhplayer, and the real MP4 sits on a
|
||||
// Cloudflare/JA3-guarded CDN, so `video.url` is the (reachable) watch page and the media is served
|
||||
// through `/proxy/hentaitv/{embedId}.mp4`, a redirect proxy that replicates nhplayer's browser
|
||||
// challenge to mint a signed CDN URL and 302s the client to it (see src/proxies/hentaitv.rs).
|
||||
|
||||
use crate::DbPool;
|
||||
use crate::api::ClientVersion;
|
||||
use crate::providers::{
|
||||
Provider, build_proxy_url, report_provider_error, report_provider_error_background,
|
||||
requester_or_default,
|
||||
};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::time::parse_time_to_seconds;
|
||||
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
crate::providers::ProviderChannelMetadata {
|
||||
group_id: "hentai-animation",
|
||||
tags: &["hentai", "anime", "subbed"],
|
||||
};
|
||||
|
||||
const CHANNEL_ID: &str = "hentaitv";
|
||||
const BASE_URL: &str = "https://hentai.tv";
|
||||
const DEFAULT_PER_PAGE: usize = 28;
|
||||
// One cheap JSON request per feed page; reuse it briefly so paging back and forth is instant.
|
||||
const CACHE_TTL_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ApiVideo {
|
||||
#[serde(default)]
|
||||
slug: String,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
ep: Option<u32>,
|
||||
#[serde(default)]
|
||||
views: Option<u64>,
|
||||
#[serde(default)]
|
||||
rating: Option<f32>,
|
||||
#[serde(default)]
|
||||
brand: Option<String>,
|
||||
#[serde(default)]
|
||||
quality: Option<String>,
|
||||
#[serde(default)]
|
||||
duration: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
cover: Option<String>,
|
||||
#[serde(default)]
|
||||
thumb: Option<String>,
|
||||
#[serde(default)]
|
||||
backdrop: Option<String>,
|
||||
#[serde(rename = "embedUrl", default)]
|
||||
embed_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiResponse {
|
||||
#[serde(default)]
|
||||
videos: Vec<ApiVideo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Target {
|
||||
/// Latest / sorted catalogue feed (paginated).
|
||||
Browse { sort: String },
|
||||
/// A single genre archive (paginated), keyed by the site's exact genre name.
|
||||
Genre { name: String, sort: String },
|
||||
/// Keyword search. The site's search is single-page, so page > 1 is empty.
|
||||
Search { query: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HentaitvProvider {
|
||||
/// Exact-cased genre names loaded from the `/browse` page, used both for the `categories`
|
||||
/// option and for routing keyword queries to a genre archive.
|
||||
genres: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl HentaitvProvider {
|
||||
pub fn new() -> Self {
|
||||
let provider = Self {
|
||||
genres: Arc::new(RwLock::new(Vec::new())),
|
||||
};
|
||||
provider.spawn_genre_load();
|
||||
provider
|
||||
}
|
||||
|
||||
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
|
||||
let genres = self.genres.read().map(|g| g.clone()).unwrap_or_default();
|
||||
|
||||
let mut cat_options: Vec<FilterOption> = vec![FilterOption {
|
||||
id: "all".to_string(),
|
||||
title: "All".to_string(),
|
||||
}];
|
||||
for name in &genres {
|
||||
cat_options.push(FilterOption {
|
||||
id: name.clone(),
|
||||
title: name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Channel {
|
||||
id: CHANNEL_ID.to_string(),
|
||||
name: "Hentai.tv".to_string(),
|
||||
description: "Subbed and raw hentai from hentai.tv with latest, trending, and genre browsing.".to_string(),
|
||||
premium: false,
|
||||
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hentai.tv".to_string(),
|
||||
status: "active".to_string(),
|
||||
categories: genres,
|
||||
options: vec![
|
||||
ChannelOption {
|
||||
id: "sort".to_string(),
|
||||
title: "Sort".to_string(),
|
||||
description: "Order the feed by newest, most viewed, or trending.".to_string(),
|
||||
systemImage: "arrow.up.arrow.down".to_string(),
|
||||
colorName: "blue".to_string(),
|
||||
options: vec![
|
||||
FilterOption { id: "new".to_string(), title: "Most Recent".to_string() },
|
||||
FilterOption { id: "views".to_string(), title: "Most Viewed".to_string() },
|
||||
FilterOption { id: "trending".to_string(), title: "Trending".to_string() },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
ChannelOption {
|
||||
id: "categories".to_string(),
|
||||
title: "Genre".to_string(),
|
||||
description: "Limit results to a single genre archive.".to_string(),
|
||||
systemImage: "tag".to_string(),
|
||||
colorName: "pink".to_string(),
|
||||
options: cat_options,
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
nsfw: true,
|
||||
cacheDuration: Some(1800),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `sort` option id to the exact label `/api/browse?sort=` expects.
|
||||
fn sort_label(sort: &str) -> &'static str {
|
||||
match sort {
|
||||
"views" | "popular" | "most-viewed" => "Most Viewed",
|
||||
"trending" | "hot" => "Trending",
|
||||
_ => "Most Recent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a free-text genre query so `school-girl`, `School Girl`, and `school girl` all
|
||||
/// resolve to the same catalogue entry.
|
||||
fn normalize_genre(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.replace(['-', '_', '+'], " ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Resolve an arbitrary input to the site's exact-cased genre name, if any.
|
||||
fn resolve_genre(&self, input: &str) -> Option<String> {
|
||||
let needle = Self::normalize_genre(input);
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let genres = self.genres.read().ok()?;
|
||||
genres
|
||||
.iter()
|
||||
.find(|name| Self::normalize_genre(name) == needle)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn pick_target(&self, query: Option<&str>, sort: &str, options: &ServerOptions) -> Target {
|
||||
let sort = Self::sort_label(sort).to_string();
|
||||
|
||||
if let Some(raw) = query {
|
||||
let q = raw.trim();
|
||||
if !q.is_empty() {
|
||||
for prefix in ["genre:", "category:", "cat:"] {
|
||||
if let Some(rest) = q.strip_prefix(prefix) {
|
||||
let name = self
|
||||
.resolve_genre(rest)
|
||||
.unwrap_or_else(|| rest.trim().to_string());
|
||||
return Target::Genre { name, sort };
|
||||
}
|
||||
}
|
||||
// A bare keyword that exactly matches a genre goes to that archive, otherwise fall
|
||||
// back to the site's keyword search.
|
||||
if let Some(name) = self.resolve_genre(q) {
|
||||
return Target::Genre { name, sort };
|
||||
}
|
||||
return Target::Search { query: q.to_string() };
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit genre selection from the `categories` option.
|
||||
if let Some(selected) = options.categories.as_deref() {
|
||||
let selected = selected.trim();
|
||||
if !selected.is_empty() && selected != "all" {
|
||||
let name = self
|
||||
.resolve_genre(selected)
|
||||
.unwrap_or_else(|| selected.to_string());
|
||||
return Target::Genre { name, sort };
|
||||
}
|
||||
}
|
||||
|
||||
Target::Browse { sort }
|
||||
}
|
||||
|
||||
fn encode(value: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Build the JSON API URL for a target/page. Returns `None` when the request would be empty by
|
||||
/// construction (search beyond page 1).
|
||||
fn build_api_url(target: &Target, page: u32) -> Option<String> {
|
||||
let page = page.max(1);
|
||||
match target {
|
||||
Target::Browse { sort } => Some(format!(
|
||||
"{BASE_URL}/api/browse?page={page}&sort={}",
|
||||
Self::encode(sort)
|
||||
)),
|
||||
Target::Genre { name, sort } => Some(format!(
|
||||
"{BASE_URL}/api/browse?page={page}&sort={}&genres={}",
|
||||
Self::encode(sort),
|
||||
Self::encode(name)
|
||||
)),
|
||||
Target::Search { query } => {
|
||||
if page > 1 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{BASE_URL}/api/search?q={}", Self::encode(query)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn abs_url(path: &str) -> String {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
path.to_string()
|
||||
} else if path.starts_with('/') {
|
||||
format!("{BASE_URL}{path}")
|
||||
} else {
|
||||
format!("{BASE_URL}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_thumb(video: &ApiVideo) -> String {
|
||||
for candidate in [&video.thumb, &video.backdrop, &video.cover] {
|
||||
if let Some(path) = candidate.as_deref().filter(|s| !s.is_empty()) {
|
||||
return Self::abs_url(path);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn extract_embed_id(embed_url: &str) -> Option<String> {
|
||||
let after = embed_url.split("/v/").nth(1)?;
|
||||
let id = after.trim_matches('/').split('/').next()?.split('?').next()?;
|
||||
(!id.is_empty()).then(|| id.to_string())
|
||||
}
|
||||
|
||||
fn build_item(video: &ApiVideo, options: &ServerOptions) -> Option<VideoItem> {
|
||||
if video.slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let embed_id = Self::extract_embed_id(video.embed_url.as_deref().unwrap_or_default())?;
|
||||
|
||||
let base_title = video.title.trim();
|
||||
let title = match video.ep {
|
||||
_ if base_title.is_empty() => video.slug.replace('-', " "),
|
||||
Some(ep) if ep >= 1 => format!("{base_title} Episode {ep}"),
|
||||
_ => base_title.to_string(),
|
||||
};
|
||||
|
||||
let url = format!("{BASE_URL}/hentai/{}", video.slug);
|
||||
let thumb = Self::pick_thumb(video);
|
||||
let duration = video
|
||||
.duration
|
||||
.as_deref()
|
||||
.and_then(parse_time_to_seconds)
|
||||
.and_then(|s| u32::try_from(s).ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let quality = video.quality.as_deref().unwrap_or_default().to_string();
|
||||
let label = if quality.is_empty() {
|
||||
"mp4".to_string()
|
||||
} else {
|
||||
quality.clone()
|
||||
};
|
||||
let proxy_url = build_proxy_url(options, CHANNEL_ID, &format!("{embed_id}.mp4"));
|
||||
let mut format = VideoFormat::new(proxy_url, label, "mp4".to_string()).ext("mp4".to_string());
|
||||
if let Some(height) = quality
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|h| *h > 0)
|
||||
{
|
||||
format = format.height(height);
|
||||
}
|
||||
|
||||
let mut item = VideoItem::new(
|
||||
video.slug.clone(),
|
||||
title,
|
||||
url,
|
||||
CHANNEL_ID.to_string(),
|
||||
thumb,
|
||||
duration,
|
||||
)
|
||||
.formats(vec![format])
|
||||
.aspect_ratio(16.0 / 9.0);
|
||||
|
||||
if let Some(views) = video.views {
|
||||
item.views = Some(views.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
if let Some(rating) = video.rating {
|
||||
// Site rating is 0-10; expose the usual 0-100 scale.
|
||||
item.rating = Some((rating * 10.0).clamp(0.0, 100.0));
|
||||
}
|
||||
if let Some(tags) = video.tags.clone().filter(|t| !t.is_empty()) {
|
||||
item.tags = Some(tags);
|
||||
}
|
||||
if let Some(brand) = video.brand.as_deref().filter(|b| !b.trim().is_empty()) {
|
||||
item.uploader = Some(brand.to_string());
|
||||
}
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
async fn fetch_page(
|
||||
&self,
|
||||
target: Target,
|
||||
page: u32,
|
||||
per_page: usize,
|
||||
cache: &VideoCache,
|
||||
options: &ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let Some(api_url) = Self::build_api_url(&target, page) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
if let Some((time, items)) = cache.get(&api_url) {
|
||||
let fresh = time
|
||||
.elapsed()
|
||||
.map(|e| e.as_secs() < CACHE_TTL_SECS)
|
||||
.unwrap_or(false);
|
||||
if fresh && !items.is_empty() {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_page");
|
||||
let body = match requester
|
||||
.get_with_headers(
|
||||
&api_url,
|
||||
vec![
|
||||
("Referer".to_string(), format!("{BASE_URL}/")),
|
||||
("Accept".to_string(), "application/json".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(body) => body,
|
||||
Err(e) => {
|
||||
report_provider_error(CHANNEL_ID, "fetch_page.request", &format!("url={api_url}; error={e}")).await;
|
||||
return cache.get(&api_url).map(|(_, items)| items).unwrap_or_default();
|
||||
}
|
||||
};
|
||||
|
||||
let response: ApiResponse = match serde_json::from_str(&body) {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
report_provider_error(CHANNEL_ID, "fetch_page.parse", &format!("url={api_url}; error={e}")).await;
|
||||
return cache.get(&api_url).map(|(_, items)| items).unwrap_or_default();
|
||||
}
|
||||
};
|
||||
|
||||
let items: Vec<VideoItem> = response
|
||||
.videos
|
||||
.iter()
|
||||
.take(per_page)
|
||||
.filter_map(|video| Self::build_item(video, options))
|
||||
.collect();
|
||||
|
||||
if !items.is_empty() {
|
||||
cache.insert(api_url, items.clone());
|
||||
items
|
||||
} else {
|
||||
cache.get(&api_url).map(|(_, items)| items).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_genre_load(&self) {
|
||||
let genres = self.genres.clone();
|
||||
std::thread::spawn(move || {
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
report_provider_error_background("hentaitv", "spawn_genre_load.runtime", &e.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut requester = crate::util::requester::Requester::new();
|
||||
let html = match requester
|
||||
.get_with_headers(
|
||||
&format!("{BASE_URL}/browse"),
|
||||
vec![("Referer".to_string(), format!("{BASE_URL}/"))],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(html) => html,
|
||||
Err(e) => {
|
||||
report_provider_error_background("hentaitv", "spawn_genre_load.fetch", &e.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parsed = Self::parse_genres(&html);
|
||||
if !parsed.is_empty() {
|
||||
if let Ok(mut guard) = genres.write() {
|
||||
*guard = parsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Pull the exact-cased genre names out of the `/browse` page payload, where they appear as
|
||||
/// `"genres":[{"name":"Big Boobs","count":2219}, ...]`.
|
||||
fn parse_genres(html: &str) -> Vec<String> {
|
||||
let unescaped = html.replace("\\\"", "\"");
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for chunk in unescaped.split("\"name\":\"").skip(1) {
|
||||
let Some(end) = chunk.find('"') else { continue };
|
||||
let name = &chunk[..end];
|
||||
// Only accept entries immediately followed by a "count" field — that is what
|
||||
// distinguishes the genre catalogue from other `"name"` objects on the page.
|
||||
let tail = chunk[end..].trim_start_matches('"').trim_start();
|
||||
if name.is_empty() || !tail.starts_with(",\"count\"") {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(name.to_string()) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HentaitvProvider {
|
||||
async fn get_videos(
|
||||
&self,
|
||||
cache: VideoCache,
|
||||
_pool: DbPool,
|
||||
sort: String,
|
||||
query: Option<String>,
|
||||
page: String,
|
||||
per_page: String,
|
||||
options: ServerOptions,
|
||||
) -> Vec<VideoItem> {
|
||||
let page = page.parse::<u32>().unwrap_or(1).max(1);
|
||||
let per_page = per_page
|
||||
.parse::<usize>()
|
||||
.unwrap_or(DEFAULT_PER_PAGE)
|
||||
.clamp(1, DEFAULT_PER_PAGE);
|
||||
|
||||
let normalized_query = query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|q| !q.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let target = self.pick_target(normalized_query.as_deref(), &sort, &options);
|
||||
|
||||
self.fetch_page(target, page, per_page, &cache, &options).await
|
||||
}
|
||||
|
||||
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
|
||||
Some(self.build_channel(clientversion))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sort_labels_map_to_api_values() {
|
||||
assert_eq!(HentaitvProvider::sort_label("new"), "Most Recent");
|
||||
assert_eq!(HentaitvProvider::sort_label("views"), "Most Viewed");
|
||||
assert_eq!(HentaitvProvider::sort_label("trending"), "Trending");
|
||||
assert_eq!(HentaitvProvider::sort_label("whatever"), "Most Recent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_api_urls() {
|
||||
let browse = Target::Browse { sort: "Most Recent".to_string() };
|
||||
assert_eq!(
|
||||
HentaitvProvider::build_api_url(&browse, 2).unwrap(),
|
||||
"https://hentai.tv/api/browse?page=2&sort=Most+Recent"
|
||||
);
|
||||
|
||||
let genre = Target::Genre { name: "Big Boobs".to_string(), sort: "Most Viewed".to_string() };
|
||||
assert_eq!(
|
||||
HentaitvProvider::build_api_url(&genre, 1).unwrap(),
|
||||
"https://hentai.tv/api/browse?page=1&sort=Most+Viewed&genres=Big+Boobs"
|
||||
);
|
||||
|
||||
let search = Target::Search { query: "school nurse".to_string() };
|
||||
assert_eq!(
|
||||
HentaitvProvider::build_api_url(&search, 1).unwrap(),
|
||||
"https://hentai.tv/api/search?q=school+nurse"
|
||||
);
|
||||
// Search has no pagination.
|
||||
assert!(HentaitvProvider::build_api_url(&search, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_embed_id() {
|
||||
assert_eq!(
|
||||
HentaitvProvider::extract_embed_id("https://nhplayer.com/v/M2WMSkkRmf5wJvq/").as_deref(),
|
||||
Some("M2WMSkkRmf5wJvq")
|
||||
);
|
||||
assert_eq!(
|
||||
HentaitvProvider::extract_embed_id("https://nhplayer.com/v/abc123?x=1").as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
assert_eq!(HentaitvProvider::extract_embed_id("https://example.com/foo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_genre_catalogue() {
|
||||
let html = r#"...,"genres":[{"name":"Big Boobs","count":2219},{"name":"incest","count":476}],"blacklist":[]..."#;
|
||||
let genres = HentaitvProvider::parse_genres(html);
|
||||
assert_eq!(genres, vec!["Big Boobs".to_string(), "incest".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_genres() {
|
||||
assert_eq!(HentaitvProvider::normalize_genre("Big Boobs"), "big boobs");
|
||||
assert_eq!(HentaitvProvider::normalize_genre("school-girl"), "school girl");
|
||||
assert_eq!(HentaitvProvider::normalize_genre(" NTR "), "ntr");
|
||||
}
|
||||
}
|
||||
326
src/proxies/animeidhentai.rs
Normal file
326
src/proxies/animeidhentai.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
// Redirect proxy for animeidhentai.com.
|
||||
//
|
||||
// animeidhentai embeds every episode through nhplayer.com (`https://nhplayer.com/v/{embedId}/`).
|
||||
// The real MP4 lives on a Cloudflare-fronted R2 bucket (`r2.1hanime.com`) behind a signed
|
||||
// `?verify=<ts>-<sig>` token produced by an obfuscated browser challenge
|
||||
// (`player.php` -> `player-core-v2.php` -> `get-video-url-v2.php`): a proof-of-work over five
|
||||
// DOM-embedded parts plus a fixed-but-valid fingerprint, which we replicate server-side.
|
||||
//
|
||||
// The signed URL additionally requires a *browser* TLS fingerprint (JA3) to clear Cloudflare.
|
||||
// curl_cffi/AVFoundation/Safari pass; our Rust HTTP stack (wreq) does NOT — every emulation
|
||||
// profile is JA3-blocked here — so we cannot stream the bytes through the server. Instead this
|
||||
// is a redirect proxy (same pattern as `jable`): HEAD returns 200 so probes/health-checks pass,
|
||||
// and GET 302-redirects to the freshly-resolved signed URL, which the Hot Tub client fetches
|
||||
// directly with its own (CF-accepted) TLS stack. yt-dlp resolves it with `--impersonate`.
|
||||
//
|
||||
// Proxy URL shape: `/proxy/animeidhentai/{embedId}.mp4` (the trailing `.mp4` is cosmetic so
|
||||
// downstream media detection keys off the extension; it is stripped before resolving).
|
||||
|
||||
use ntex::http::Method;
|
||||
use ntex::http::header::{ACCEPT_RANGES, CONTENT_TYPE, LOCATION};
|
||||
use ntex::web::{self, HttpRequest};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
// Mirror of JavaScript encodeURIComponent: keep the unreserved marks `-_.!~*'()` unescaped.
|
||||
const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'!')
|
||||
.remove(b'~')
|
||||
.remove(b'*')
|
||||
.remove(b'\'')
|
||||
.remove(b'(')
|
||||
.remove(b')');
|
||||
|
||||
const NH_BASE: &str = "https://nhplayer.com";
|
||||
const SITE_REFERER: &str = "https://animeidhentai.com/";
|
||||
// Static but plausible desktop-Firefox fingerprint. nhplayer's server only sanity-checks the
|
||||
// shape plus the proof-of-work / detection flags, so a fixed payload validates reliably.
|
||||
const FINGERPRINT_JSON: &str = r#"{"t":1500,"mm":[[120,210,260],[180,260,640]],"tm":[],"cl":[[190,300,1180]],"kp":[],"sc":[],"i":1,"mc":2,"tc":0,"cc":1,"kc":0,"b":{"w":"ANGLE (Intel, Intel(R) UHD Graphics, OpenGL 4.6)","v":"Google Inc. (Intel)","sw":1920,"sh":1080,"aw":1920,"ah":1040,"cd":24,"pd":24,"tz":-60,"hc":8,"dm":8,"pl":"Win32","lang":"en-US","langs":"en-US,en","dpr":1,"ww":1280,"wh":720,"touch":false,"pdf":true,"fonts":0}}"#;
|
||||
|
||||
static DATA_ID_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"data-id="(/player\.php\?[^"]+)""#).unwrap());
|
||||
static PV_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"_pV=\{vid:"([^"]+)",ct:"([^"]+)",pid:"([^"]+)",st:"([^"]+)"\}"#).unwrap()
|
||||
});
|
||||
static CORE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"player-core-v2\.php\?t=([A-Za-z0-9+/=]+)"#).unwrap());
|
||||
static SC_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"'([0-9a-f]+\.[0-9a-f]+)'"#).unwrap());
|
||||
static RID_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"id:'([0-9a-f]{16})'"#).unwrap());
|
||||
static ELEM_ID_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"getElementById\('([^']+)'\)"#).unwrap());
|
||||
static ATTR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"getAttribute\('([^']+)'\)"#).unwrap());
|
||||
static URL_FIELD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""url"\s*:\s*"([^"]+)""#).unwrap());
|
||||
|
||||
// embedId -> (signed media url, fetched-at). The verify token is short lived, so we keep it brief.
|
||||
static URL_CACHE: Lazy<Mutex<HashMap<String, (String, Instant)>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
const CACHE_TTL: Duration = Duration::from_secs(150);
|
||||
|
||||
fn cache_get(embed_id: &str) -> Option<String> {
|
||||
let cache = URL_CACHE.lock().ok()?;
|
||||
let (url, at) = cache.get(embed_id)?;
|
||||
if at.elapsed() < CACHE_TTL {
|
||||
Some(url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_put(embed_id: &str, url: &str) {
|
||||
if let Ok(mut cache) = URL_CACHE.lock() {
|
||||
cache.insert(embed_id.to_string(), (url.to_string(), Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a challenge part from `player.php` by element id, tolerating either attribute order.
|
||||
fn read_attr(html: &str, id: &str, attr: &str) -> Option<String> {
|
||||
let id_q = regex::escape(id);
|
||||
let attr_q = regex::escape(attr);
|
||||
let forward = Regex::new(&format!(r#"id="{id_q}"[^>]*?{attr_q}="([^"]*)""#)).ok()?;
|
||||
if let Some(c) = forward.captures(html) {
|
||||
return Some(c[1].to_string());
|
||||
}
|
||||
let backward = Regex::new(&format!(r#"{attr_q}="([^"]*)"[^>]*?id="{id_q}""#)).ok()?;
|
||||
backward.captures(html).map(|c| c[1].to_string())
|
||||
}
|
||||
|
||||
fn read_input_value(html: &str, id: &str) -> Option<String> {
|
||||
read_attr(html, id, "value")
|
||||
}
|
||||
|
||||
fn read_template_text(html: &str, id: &str) -> Option<String> {
|
||||
let id_q = regex::escape(id);
|
||||
let re = Regex::new(&format!(r#"<template id="{id_q}"><p>([^<]*)</p>"#)).ok()?;
|
||||
re.captures(html).map(|c| c[1].to_string())
|
||||
}
|
||||
|
||||
fn solve_pow(challenge: &str) -> String {
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(challenge.as_bytes());
|
||||
hasher.update(format!("{n:x}").as_bytes());
|
||||
if hasher.finalize()[0] == 0 {
|
||||
return format!("{n:x}");
|
||||
}
|
||||
n += 1;
|
||||
if n > 50_000_000 {
|
||||
// statistically unreachable (1/256 success per try); bail rather than spin forever.
|
||||
return format!("{n:x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_text(requester: &mut Requester, url: &str, referer: &str) -> Option<String> {
|
||||
let resp = requester
|
||||
.get_raw_with_headers(url, vec![("Referer".to_string(), referer.to_string())])
|
||||
.await
|
||||
.ok()?;
|
||||
resp.text().await.ok()
|
||||
}
|
||||
|
||||
/// Replicate nhplayer's browser challenge and return the signed CDN MP4 URL for an embed id.
|
||||
async fn resolve_signed_url(requester: &mut Requester, embed_id: &str) -> Option<String> {
|
||||
// 1. Embed page -> player.php path (carries the base64 media token).
|
||||
let embed_url = format!("{NH_BASE}/v/{embed_id}/");
|
||||
let embed_html = fetch_text(requester, &embed_url, SITE_REFERER).await?;
|
||||
let player_path = DATA_ID_RE
|
||||
.captures(&embed_html)?
|
||||
.get(1)?
|
||||
.as_str()
|
||||
.replace("&", "&");
|
||||
|
||||
// 2. player.php -> _pV inputs, the player-core token, and the DOM challenge parts.
|
||||
let player_url = format!("{NH_BASE}{player_path}");
|
||||
let player_html = fetch_text(requester, &player_url, &embed_url).await?;
|
||||
let pv = PV_RE.captures(&player_html)?;
|
||||
let vid = pv.get(1)?.as_str().to_string();
|
||||
let ct = pv.get(2)?.as_str().to_string();
|
||||
let pid = pv.get(3)?.as_str().to_string();
|
||||
let st = pv.get(4)?.as_str().to_string();
|
||||
let core_token = CORE_RE.captures(&player_html)?.get(1)?.as_str().to_string();
|
||||
|
||||
// 3. player-core-v2.php -> server-challenge token, request id, and (randomized) element refs.
|
||||
let core_url = format!("{NH_BASE}/player-core-v2.php?t={core_token}");
|
||||
let core_js = fetch_text(requester, &core_url, &player_url).await?;
|
||||
let sc = SC_RE.captures(&core_js)?.get(1)?.as_str().to_string();
|
||||
let rid = RID_RE.captures(&core_js)?.get(1)?.as_str().to_string();
|
||||
let ids: Vec<String> = ELEM_ID_RE
|
||||
.captures_iter(&core_js)
|
||||
.take(5)
|
||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
let attrs: Vec<String> = ATTR_RE
|
||||
.captures_iter(&core_js)
|
||||
.take(3)
|
||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
if ids.len() < 5 || attrs.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 4. Read the five challenge parts out of player.php using the discovered ids/attrs.
|
||||
crate::flow_debug!(
|
||||
"aih resolve embed={} player_html={}B core_js={}B sc={} rid={} ids={} attrs={}",
|
||||
embed_id,
|
||||
player_html.len(),
|
||||
core_js.len(),
|
||||
sc,
|
||||
rid,
|
||||
ids.len(),
|
||||
attrs.len()
|
||||
);
|
||||
|
||||
let p1 = read_attr(&player_html, &ids[0], &attrs[0])?;
|
||||
let p2 = read_input_value(&player_html, &ids[1])?;
|
||||
let p3 = read_attr(&player_html, &ids[2], &attrs[1])?;
|
||||
let p4 = read_template_text(&player_html, &ids[3])?;
|
||||
let ts = read_attr(&player_html, &ids[4], &attrs[2])?;
|
||||
|
||||
// 5. Proof of work over the concatenated parts (first SHA-256 byte must be zero).
|
||||
let pow = solve_pow(&format!("{p1}{p2}{p3}{p4}{ts}"));
|
||||
let fp = STANDARD.encode(FINGERPRINT_JSON.as_bytes());
|
||||
|
||||
// The server enforces a minimum dwell time (>= 700ms) between the challenge being issued
|
||||
// and the URL request; the browser sleeps for the same reason. Without this the endpoint
|
||||
// rejects the request and returns no url.
|
||||
tokio::time::sleep(Duration::from_millis(900)).await;
|
||||
|
||||
// 6. Ask the server to mint the signed URL. Encode like the browser's encodeURIComponent
|
||||
// (unreserved marks left intact) so the server validates exactly as it would in-browser.
|
||||
let enc = |s: &str| {
|
||||
percent_encoding::utf8_percent_encode(s, COMPONENT).to_string()
|
||||
};
|
||||
let query = format!(
|
||||
"vid={}&c={}&p1={}&p2={}&p3={}&p4={}&t={}&sc={}&rid={}&fp={}&df=&pow={}&pid={}&st={}",
|
||||
enc(&vid),
|
||||
enc(&ct),
|
||||
enc(&p1),
|
||||
enc(&p2),
|
||||
enc(&p3),
|
||||
enc(&p4),
|
||||
enc(&ts),
|
||||
enc(&sc),
|
||||
enc(&rid),
|
||||
enc(&fp),
|
||||
enc(&pow),
|
||||
enc(&pid),
|
||||
enc(&st),
|
||||
);
|
||||
let final_url = format!("{NH_BASE}/get-video-url-v2.php?{query}");
|
||||
let resp = requester
|
||||
.get_raw_with_headers(
|
||||
&final_url,
|
||||
vec![
|
||||
("Referer".to_string(), player_url.clone()),
|
||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let status = resp.status();
|
||||
#[cfg(not(feature = "debug"))]
|
||||
let _ = &status;
|
||||
let body = resp.text().await.ok()?;
|
||||
crate::flow_debug!(
|
||||
"aih resolve embed={} get-video-url status={} body={}",
|
||||
embed_id,
|
||||
status.as_u16(),
|
||||
crate::util::flow_debug::preview(&body, 200)
|
||||
);
|
||||
let captures = URL_FIELD_RE.captures(&body)?;
|
||||
let url = captures.get(1)?.as_str().replace("\\/", "/");
|
||||
if url.starts_with("http") {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_cached(requester: &mut Requester, embed_id: &str, force: bool) -> Option<String> {
|
||||
if !force {
|
||||
if let Some(url) = cache_get(embed_id) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
let url = resolve_signed_url(requester, embed_id).await?;
|
||||
cache_put(embed_id, &url);
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn embed_id_from_endpoint(endpoint: &str) -> String {
|
||||
endpoint
|
||||
.trim_matches('/')
|
||||
.trim_end_matches(".mp4")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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 embed_id = embed_id_from_endpoint(&endpoint);
|
||||
if embed_id.is_empty() {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
}
|
||||
|
||||
// HEAD: answer probes/health-checks directly. We can't fetch the CF-guarded CDN from this
|
||||
// TLS stack, but the resource is a direct MP4, so advertise it as one without touching it.
|
||||
if req.method() == Method::HEAD {
|
||||
return Ok(web::HttpResponse::Ok()
|
||||
.header(CONTENT_TYPE, "video/mp4")
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.finish());
|
||||
}
|
||||
|
||||
// GET/POST: resolve the signed CDN URL and 302 to it so the client fetches it directly.
|
||||
let mut requester = requester.get_ref().clone();
|
||||
match resolve_cached(&mut requester, &embed_id, false).await {
|
||||
Some(signed_url) => Ok(web::HttpResponse::Found()
|
||||
.header(LOCATION, signed_url)
|
||||
.finish()),
|
||||
None => Ok(web::HttpResponse::BadGateway().finish()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_mp4_suffix_and_slashes() {
|
||||
assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
|
||||
assert_eq!(embed_id_from_endpoint("/M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
|
||||
assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq"), "M2WMSkkRmf5wJvq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_first_byte_is_zero() {
|
||||
let answer = solve_pow("abcd1234");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update("abcd1234".as_bytes());
|
||||
hasher.update(answer.as_bytes());
|
||||
assert_eq!(hasher.finalize()[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_challenge_parts_in_either_order() {
|
||||
let html = r#"<span id="xb01505" data-b74a="c13d97ee"></span><input type="hidden" id="xfc1ef6" value="fb3c856e"><template id="x60ad50"><p>81b9beee</p></template>"#;
|
||||
assert_eq!(read_attr(html, "xb01505", "data-b74a").as_deref(), Some("c13d97ee"));
|
||||
assert_eq!(read_input_value(html, "xfc1ef6").as_deref(), Some("fb3c856e"));
|
||||
assert_eq!(read_template_text(html, "x60ad50").as_deref(), Some("81b9beee"));
|
||||
}
|
||||
}
|
||||
324
src/proxies/hentaitv.rs
Normal file
324
src/proxies/hentaitv.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
// Redirect proxy for hentai.tv.
|
||||
//
|
||||
// hentai.tv embeds every episode through nhplayer.com (`https://nhplayer.com/v/{embedId}/`) and
|
||||
// the real MP4 lives on the same Cloudflare-fronted R2 bucket (`r2.1hanime.com`) as
|
||||
// `animeidhentai`, behind a signed `?verify=<ts>-<sig>` token produced by an obfuscated browser
|
||||
// challenge (`player.php` -> `player-core-v2.php` -> `get-video-url-v2.php`): a proof-of-work over
|
||||
// five DOM-embedded parts plus a fixed-but-valid fingerprint, replicated server-side below.
|
||||
//
|
||||
// The signed URL additionally requires a *browser* TLS fingerprint (JA3) to clear Cloudflare.
|
||||
// curl_cffi/AVFoundation/Safari and yt-dlp `--impersonate` pass; our Rust HTTP stack (wreq) is
|
||||
// JA3-blocked on every emulation profile, so we cannot stream the bytes ourselves. Instead this is
|
||||
// a redirect proxy (same pattern as `jable`/`animeidhentai`): HEAD returns 200 so probes and
|
||||
// health-checks pass, and GET 302-redirects to the freshly-resolved signed URL, which the Hot Tub
|
||||
// client fetches directly with its own (CF-accepted) TLS stack.
|
||||
//
|
||||
// Proxy URL shape: `/proxy/hentaitv/{embedId}.mp4` (the trailing `.mp4` is cosmetic so downstream
|
||||
// media detection keys off the extension; it is stripped before resolving).
|
||||
|
||||
use ntex::http::Method;
|
||||
use ntex::http::header::{ACCEPT_RANGES, CONTENT_TYPE, LOCATION};
|
||||
use ntex::web::{self, HttpRequest};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
// Mirror of JavaScript encodeURIComponent: keep the unreserved marks `-_.!~*'()` unescaped.
|
||||
const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'!')
|
||||
.remove(b'~')
|
||||
.remove(b'*')
|
||||
.remove(b'\'')
|
||||
.remove(b'(')
|
||||
.remove(b')');
|
||||
|
||||
const NH_BASE: &str = "https://nhplayer.com";
|
||||
const SITE_REFERER: &str = "https://hentai.tv/";
|
||||
// Static but plausible desktop-Firefox fingerprint. nhplayer's server only sanity-checks the
|
||||
// shape plus the proof-of-work / detection flags, so a fixed payload validates reliably.
|
||||
const FINGERPRINT_JSON: &str = r#"{"t":1500,"mm":[[120,210,260],[180,260,640]],"tm":[],"cl":[[190,300,1180]],"kp":[],"sc":[],"i":1,"mc":2,"tc":0,"cc":1,"kc":0,"b":{"w":"ANGLE (Intel, Intel(R) UHD Graphics, OpenGL 4.6)","v":"Google Inc. (Intel)","sw":1920,"sh":1080,"aw":1920,"ah":1040,"cd":24,"pd":24,"tz":-60,"hc":8,"dm":8,"pl":"Win32","lang":"en-US","langs":"en-US,en","dpr":1,"ww":1280,"wh":720,"touch":false,"pdf":true,"fonts":0}}"#;
|
||||
|
||||
static DATA_ID_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"data-id="(/player\.php\?[^"]+)""#).unwrap());
|
||||
static PV_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"_pV=\{vid:"([^"]+)",ct:"([^"]+)",pid:"([^"]+)",st:"([^"]+)"\}"#).unwrap()
|
||||
});
|
||||
static CORE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"player-core-v2\.php\?t=([A-Za-z0-9+/=]+)"#).unwrap());
|
||||
static SC_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"'([0-9a-f]+\.[0-9a-f]+)'"#).unwrap());
|
||||
static RID_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"id:'([0-9a-f]{16})'"#).unwrap());
|
||||
static ELEM_ID_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"getElementById\('([^']+)'\)"#).unwrap());
|
||||
static ATTR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"getAttribute\('([^']+)'\)"#).unwrap());
|
||||
static URL_FIELD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""url"\s*:\s*"([^"]+)""#).unwrap());
|
||||
|
||||
// embedId -> (signed media url, fetched-at). The verify token is short lived, so we keep it brief.
|
||||
static URL_CACHE: Lazy<Mutex<HashMap<String, (String, Instant)>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
const CACHE_TTL: Duration = Duration::from_secs(150);
|
||||
|
||||
fn cache_get(embed_id: &str) -> Option<String> {
|
||||
let cache = URL_CACHE.lock().ok()?;
|
||||
let (url, at) = cache.get(embed_id)?;
|
||||
if at.elapsed() < CACHE_TTL {
|
||||
Some(url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_put(embed_id: &str, url: &str) {
|
||||
if let Ok(mut cache) = URL_CACHE.lock() {
|
||||
cache.insert(embed_id.to_string(), (url.to_string(), Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a challenge part from `player.php` by element id, tolerating either attribute order.
|
||||
fn read_attr(html: &str, id: &str, attr: &str) -> Option<String> {
|
||||
let id_q = regex::escape(id);
|
||||
let attr_q = regex::escape(attr);
|
||||
let forward = Regex::new(&format!(r#"id="{id_q}"[^>]*?{attr_q}="([^"]*)""#)).ok()?;
|
||||
if let Some(c) = forward.captures(html) {
|
||||
return Some(c[1].to_string());
|
||||
}
|
||||
let backward = Regex::new(&format!(r#"{attr_q}="([^"]*)"[^>]*?id="{id_q}""#)).ok()?;
|
||||
backward.captures(html).map(|c| c[1].to_string())
|
||||
}
|
||||
|
||||
fn read_input_value(html: &str, id: &str) -> Option<String> {
|
||||
read_attr(html, id, "value")
|
||||
}
|
||||
|
||||
fn read_template_text(html: &str, id: &str) -> Option<String> {
|
||||
let id_q = regex::escape(id);
|
||||
let re = Regex::new(&format!(r#"<template id="{id_q}"><p>([^<]*)</p>"#)).ok()?;
|
||||
re.captures(html).map(|c| c[1].to_string())
|
||||
}
|
||||
|
||||
fn solve_pow(challenge: &str) -> String {
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(challenge.as_bytes());
|
||||
hasher.update(format!("{n:x}").as_bytes());
|
||||
if hasher.finalize()[0] == 0 {
|
||||
return format!("{n:x}");
|
||||
}
|
||||
n += 1;
|
||||
if n > 50_000_000 {
|
||||
// statistically unreachable (1/256 success per try); bail rather than spin forever.
|
||||
return format!("{n:x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_text(requester: &mut Requester, url: &str, referer: &str) -> Option<String> {
|
||||
let resp = requester
|
||||
.get_raw_with_headers(url, vec![("Referer".to_string(), referer.to_string())])
|
||||
.await
|
||||
.ok()?;
|
||||
resp.text().await.ok()
|
||||
}
|
||||
|
||||
/// Replicate nhplayer's browser challenge and return the signed CDN MP4 URL for an embed id.
|
||||
async fn resolve_signed_url(requester: &mut Requester, embed_id: &str) -> Option<String> {
|
||||
// 1. Embed page -> player.php path (carries the base64 media token).
|
||||
let embed_url = format!("{NH_BASE}/v/{embed_id}/");
|
||||
let embed_html = fetch_text(requester, &embed_url, SITE_REFERER).await?;
|
||||
let player_path = DATA_ID_RE
|
||||
.captures(&embed_html)?
|
||||
.get(1)?
|
||||
.as_str()
|
||||
.replace("&", "&");
|
||||
|
||||
// 2. player.php -> _pV inputs, the player-core token, and the DOM challenge parts.
|
||||
let player_url = format!("{NH_BASE}{player_path}");
|
||||
let player_html = fetch_text(requester, &player_url, &embed_url).await?;
|
||||
let pv = PV_RE.captures(&player_html)?;
|
||||
let vid = pv.get(1)?.as_str().to_string();
|
||||
let ct = pv.get(2)?.as_str().to_string();
|
||||
let pid = pv.get(3)?.as_str().to_string();
|
||||
let st = pv.get(4)?.as_str().to_string();
|
||||
let core_token = CORE_RE.captures(&player_html)?.get(1)?.as_str().to_string();
|
||||
|
||||
// 3. player-core-v2.php -> server-challenge token, request id, and (randomized) element refs.
|
||||
let core_url = format!("{NH_BASE}/player-core-v2.php?t={core_token}");
|
||||
let core_js = fetch_text(requester, &core_url, &player_url).await?;
|
||||
let sc = SC_RE.captures(&core_js)?.get(1)?.as_str().to_string();
|
||||
let rid = RID_RE.captures(&core_js)?.get(1)?.as_str().to_string();
|
||||
let ids: Vec<String> = ELEM_ID_RE
|
||||
.captures_iter(&core_js)
|
||||
.take(5)
|
||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
let attrs: Vec<String> = ATTR_RE
|
||||
.captures_iter(&core_js)
|
||||
.take(3)
|
||||
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
if ids.len() < 5 || attrs.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
crate::flow_debug!(
|
||||
"hentaitv resolve embed={} player_html={}B core_js={}B sc={} rid={} ids={} attrs={}",
|
||||
embed_id,
|
||||
player_html.len(),
|
||||
core_js.len(),
|
||||
sc,
|
||||
rid,
|
||||
ids.len(),
|
||||
attrs.len()
|
||||
);
|
||||
|
||||
// 4. Read the five challenge parts out of player.php using the discovered ids/attrs.
|
||||
let p1 = read_attr(&player_html, &ids[0], &attrs[0])?;
|
||||
let p2 = read_input_value(&player_html, &ids[1])?;
|
||||
let p3 = read_attr(&player_html, &ids[2], &attrs[1])?;
|
||||
let p4 = read_template_text(&player_html, &ids[3])?;
|
||||
let ts = read_attr(&player_html, &ids[4], &attrs[2])?;
|
||||
|
||||
// 5. Proof of work over the concatenated parts (first SHA-256 byte must be zero).
|
||||
let pow = solve_pow(&format!("{p1}{p2}{p3}{p4}{ts}"));
|
||||
let fp = STANDARD.encode(FINGERPRINT_JSON.as_bytes());
|
||||
|
||||
// The server enforces a minimum dwell time (>= 700ms) between the challenge being issued and
|
||||
// the URL request; the browser sleeps for the same reason. Without this the endpoint rejects
|
||||
// the request and returns no url.
|
||||
tokio::time::sleep(Duration::from_millis(900)).await;
|
||||
|
||||
// 6. Ask the server to mint the signed URL. Encode like the browser's encodeURIComponent
|
||||
// (unreserved marks left intact) so the server validates exactly as it would in-browser.
|
||||
let enc = |s: &str| percent_encoding::utf8_percent_encode(s, COMPONENT).to_string();
|
||||
let query = format!(
|
||||
"vid={}&c={}&p1={}&p2={}&p3={}&p4={}&t={}&sc={}&rid={}&fp={}&df=&pow={}&pid={}&st={}",
|
||||
enc(&vid),
|
||||
enc(&ct),
|
||||
enc(&p1),
|
||||
enc(&p2),
|
||||
enc(&p3),
|
||||
enc(&p4),
|
||||
enc(&ts),
|
||||
enc(&sc),
|
||||
enc(&rid),
|
||||
enc(&fp),
|
||||
enc(&pow),
|
||||
enc(&pid),
|
||||
enc(&st),
|
||||
);
|
||||
let final_url = format!("{NH_BASE}/get-video-url-v2.php?{query}");
|
||||
let resp = requester
|
||||
.get_raw_with_headers(
|
||||
&final_url,
|
||||
vec![
|
||||
("Referer".to_string(), player_url.clone()),
|
||||
("X-Requested-With".to_string(), "XMLHttpRequest".to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let status = resp.status();
|
||||
#[cfg(not(feature = "debug"))]
|
||||
let _ = &status;
|
||||
let body = resp.text().await.ok()?;
|
||||
crate::flow_debug!(
|
||||
"hentaitv resolve embed={} get-video-url status={} body={}",
|
||||
embed_id,
|
||||
status.as_u16(),
|
||||
crate::util::flow_debug::preview(&body, 200)
|
||||
);
|
||||
let captures = URL_FIELD_RE.captures(&body)?;
|
||||
let url = captures.get(1)?.as_str().replace("\\/", "/");
|
||||
if url.starts_with("http") {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_cached(requester: &mut Requester, embed_id: &str, force: bool) -> Option<String> {
|
||||
if !force {
|
||||
if let Some(url) = cache_get(embed_id) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
let url = resolve_signed_url(requester, embed_id).await?;
|
||||
cache_put(embed_id, &url);
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn embed_id_from_endpoint(endpoint: &str) -> String {
|
||||
endpoint
|
||||
.trim_matches('/')
|
||||
.trim_end_matches(".mp4")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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 embed_id = embed_id_from_endpoint(&endpoint);
|
||||
if embed_id.is_empty() {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
}
|
||||
|
||||
// HEAD: answer probes/health-checks directly. We can't fetch the CF-guarded CDN from this TLS
|
||||
// stack, but the resource is a direct MP4, so advertise it as one without touching it.
|
||||
if req.method() == Method::HEAD {
|
||||
return Ok(web::HttpResponse::Ok()
|
||||
.header(CONTENT_TYPE, "video/mp4")
|
||||
.header(ACCEPT_RANGES, "bytes")
|
||||
.finish());
|
||||
}
|
||||
|
||||
// GET/POST: resolve the signed CDN URL and 302 to it so the client fetches it directly.
|
||||
let mut requester = requester.get_ref().clone();
|
||||
match resolve_cached(&mut requester, &embed_id, false).await {
|
||||
Some(signed_url) => Ok(web::HttpResponse::Found()
|
||||
.header(LOCATION, signed_url)
|
||||
.finish()),
|
||||
None => Ok(web::HttpResponse::BadGateway().finish()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_mp4_suffix_and_slashes() {
|
||||
assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
|
||||
assert_eq!(embed_id_from_endpoint("/M2WMSkkRmf5wJvq.mp4"), "M2WMSkkRmf5wJvq");
|
||||
assert_eq!(embed_id_from_endpoint("M2WMSkkRmf5wJvq"), "M2WMSkkRmf5wJvq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_first_byte_is_zero() {
|
||||
let answer = solve_pow("abcd1234");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update("abcd1234".as_bytes());
|
||||
hasher.update(answer.as_bytes());
|
||||
assert_eq!(hasher.finalize()[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_challenge_parts_in_either_order() {
|
||||
let html = r#"<span id="xb01505" data-b74a="c13d97ee"></span><input type="hidden" id="xfc1ef6" value="fb3c856e"><template id="x60ad50"><p>81b9beee</p></template>"#;
|
||||
assert_eq!(read_attr(html, "xb01505", "data-b74a").as_deref(), Some("c13d97ee"));
|
||||
assert_eq!(read_input_value(html, "xfc1ef6").as_deref(), Some("fb3c856e"));
|
||||
assert_eq!(read_template_text(html, "x60ad50").as_deref(), Some("81b9beee"));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use crate::proxies::lulustream::LulustreamProxy;
|
||||
use crate::proxies::thaiporntv::ThaipornTvProxy;
|
||||
|
||||
pub mod allpornstream;
|
||||
pub mod animeidhentai;
|
||||
pub mod archivebate;
|
||||
pub mod clapdat;
|
||||
pub mod doodstream;
|
||||
@@ -25,6 +26,7 @@ pub mod fikfapthumb;
|
||||
pub mod hanimecdn;
|
||||
pub mod hanimethumb;
|
||||
pub mod heavyfetish;
|
||||
pub mod hentaitv;
|
||||
pub mod hqporner;
|
||||
pub mod hqpornerthumb;
|
||||
pub mod javtiful;
|
||||
|
||||
12
src/proxy.rs
12
src/proxy.rs
@@ -101,6 +101,18 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::post().to(crate::proxies::noodlemagazine::serve_media))
|
||||
.route(web::get().to(crate::proxies::noodlemagazine::serve_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/animeidhentai/{endpoint}*")
|
||||
.route(web::get().to(crate::proxies::animeidhentai::serve_media))
|
||||
.route(web::post().to(crate::proxies::animeidhentai::serve_media))
|
||||
.route(web::head().to(crate::proxies::animeidhentai::serve_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hentaitv/{endpoint}*")
|
||||
.route(web::get().to(crate::proxies::hentaitv::serve_media))
|
||||
.route(web::post().to(crate::proxies::hentaitv::serve_media))
|
||||
.route(web::head().to(crate::proxies::hentaitv::serve_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/noodlemagazine-thumb/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::noodlemagazine::get_image))
|
||||
|
||||
Reference in New Issue
Block a user