hentai
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user