// 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=`. 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::() + chars.as_str(), None => String::new(), } }) .collect::>() .join(" ") } fn build_channel(&self, _cv: ClientVersion) -> Channel { let mut cat_options: Vec = 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 { 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 { 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 = 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::() .parse::() .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> { 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 { 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 = 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, page: String, _per_page: String, options: ServerOptions, ) -> Vec { let page = page.parse::().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 { Some(self.build_channel(cv)) } }