363 lines
12 KiB
Rust
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))
|
|
}
|
|
}
|