Files
hottub/src/providers/animeidhentai.rs
2026-07-06 16:07:54 +00:00

363 lines
12 KiB
Rust

// 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),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
}
}
/// 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))
}
}