This commit is contained in:
Simon
2026-09-07 08:02:15 +00:00
parent 1537836b19
commit ee5e124056
7 changed files with 482 additions and 260 deletions

View File

@@ -1,7 +1,13 @@
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use async_trait::async_trait;
use base64::Engine;
use error_chain::error_chain;
use futures::future::join_all;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use std::vec;
use crate::DbPool;
@@ -25,81 +31,142 @@ error_chain! {
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct HanimeSearchRequest {
search_text: String,
tags: Vec<String>,
tags_mode: String,
brands: Vec<String>,
blacklist: Vec<String>,
order_by: String,
ordering: String,
page: u8,
}
/// Full catalogue dump. Ignores every query parameter, so filtering, sorting and
/// pagination all happen client side. ~860 KB compressed, CDN cached for an hour.
const INDEX_URL: &str = "https://guest.freeanimehentai.net/api/v11/search_hvs";
const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake";
const SITE_ORIGIN: &str = "https://hanime.tv";
/// Envelope encryption used for the handshake request/response bodies.
const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1";
const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1";
/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module.
const SIGNATURE_SECRET: &str = "Xkdi29";
const SIGNATURE_SALT: &str = "mn2";
const INDEX_TTL_SECS: u64 = 3600;
impl HanimeSearchRequest {
pub fn new() -> Self {
HanimeSearchRequest {
search_text: "".to_string(),
tags: vec![],
tags_mode: "AND".to_string(),
brands: vec![],
blacklist: vec![],
order_by: "created_at_unix".to_string(),
ordering: "desc".to_string(),
page: 0,
}
}
pub fn search_text(mut self, search_text: String) -> Self {
self.search_text = search_text;
self
}
pub fn order_by(mut self, order_by: String) -> Self {
self.order_by = order_by;
self
}
pub fn ordering(mut self, ordering: String) -> Self {
self.ordering = ordering;
self
}
pub fn page(mut self, page: u8) -> Self {
self.page = page;
self
}
}
type IndexCache = OnceLock<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>;
static INDEX_CACHE: IndexCache = OnceLock::new();
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct HanimeSearchResponse {
page: u8,
nbPages: u8,
nbHits: u32,
hitsPerPage: u8,
hits: String,
#[derive(serde::Deserialize, Debug)]
struct HanimeIndexResponse {
data: Vec<HanimeSearchResult>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct HanimeSearchResult {
id: u64,
name: String,
titles: Vec<String>,
#[serde(default)]
search_titles: String,
slug: String,
description: String,
#[serde(default)]
views: u64,
interests: u64,
poster_url: String,
cover_url: String,
#[serde(default)]
brand: String,
brand_id: u64,
duration_in_ms: u32,
is_censored: bool,
rating: Option<u32>,
#[serde(default)]
likes: u64,
#[serde(default)]
dislikes: u64,
downloads: u64,
monthly_ranked: Option<u64>,
#[serde(default)]
tags: Vec<String>,
created_at: u64,
released_at: u64,
#[serde(default)]
created_at_unix: u64,
#[serde(default)]
released_at_unix: u64,
}
#[derive(serde::Deserialize, Debug)]
struct HanimeHandshakePayload {
#[serde(default)]
sources: Vec<HanimeSource>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct HanimeSource {
#[serde(default)]
src: String,
#[serde(default)]
height: u32,
#[serde(default)]
label: String,
/// `"normal"` for the free streams, `"promotion"` for the premium-only teaser
/// entries which always carry an empty `src`.
#[serde(default)]
kind: String,
}
fn b64() -> base64::engine::general_purpose::GeneralPurpose {
base64::engine::general_purpose::URL_SAFE_NO_PAD
}
fn b64_decode(value: &str) -> Result<Vec<u8>> {
b64()
.decode(value.trim_end_matches('='))
.map_err(|e| Error::from(format!("base64url decode failed: {e}")))
}
fn handshake_cipher() -> Aes256Gcm {
let digest = Sha256::digest(HANDSHAKE_KEY_SEED);
let key = Key::<Aes256Gcm>::from_slice(digest.as_slice());
Aes256Gcm::new(key)
}
/// `{"v":1,"alg":"AES-256-GCM","iv":..,"tag":..,"data":..}`, base64url encoded.
fn seal_envelope(plaintext: &[u8]) -> Result<String> {
let mut iv = [0u8; 12];
rand::fill(&mut iv);
let sealed = handshake_cipher()
.encrypt(
Nonce::from_slice(&iv),
Payload {
msg: plaintext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| Error::from(format!("handshake encrypt failed: {e}")))?;
if sealed.len() < 16 {
return Err(Error::from("handshake ciphertext too short"));
}
let (data, tag) = sealed.split_at(sealed.len() - 16);
let envelope = json!({
"v": 1,
"alg": "AES-256-GCM",
"iv": b64().encode(iv),
"tag": b64().encode(tag),
"data": b64().encode(data),
});
Ok(b64().encode(envelope.to_string().as_bytes()))
}
fn open_envelope(token: &str) -> Result<Vec<u8>> {
let envelope: serde_json::Value = serde_json::from_slice(&b64_decode(token)?)
.map_err(|e| Error::from(format!("handshake envelope is not JSON: {e}")))?;
let field = |name: &str| -> Result<Vec<u8>> {
let raw = envelope
.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| Error::from(format!("handshake envelope missing {name}")))?;
b64_decode(raw)
};
let iv = field("iv")?;
let mut ciphertext = field("data")?;
ciphertext.extend_from_slice(&field("tag")?);
handshake_cipher()
.decrypt(
Nonce::from_slice(&iv),
Payload {
msg: &ciphertext,
aad: HANDSHAKE_AAD,
},
)
.map_err(|e| Error::from(format!("handshake decrypt failed: {e}")))
}
fn signature_for(timestamp: u64) -> String {
let pre_image =
format!("{timestamp},{SIGNATURE_SECRET},{SITE_ORIGIN},{SIGNATURE_SALT},{timestamp}");
hex::encode(Sha256::digest(pre_image.as_bytes()))
}
#[derive(Debug, Clone)]
@@ -171,109 +238,199 @@ impl HanimeProvider {
}],
nsfw: true,
cacheDuration: None,
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string())
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn db_key(slug: &str) -> String {
format!("https://h.freeanimehentai.net/api/v8/video?id={slug}&")
format!("https://hanime.tv/videos/hentai/{slug}")
}
fn build_video_item(
id: String,
title: String,
hit: &HanimeSearchResult,
video_url: String,
channel: String,
thumb: String,
duration: u32,
tags: Vec<String>,
brand: String,
views: u64,
likes: u64,
dislikes: u64,
formats: Vec<videos::VideoFormat>,
) -> VideoItem {
VideoItem::new(id, title, video_url.clone(), channel, thumb, duration)
.tags(tags)
.uploader(brand)
.views(views as u32)
.rating((likes as f32 / (likes + dislikes) as f32) * 100_f32)
.aspect_ratio(0.68)
.formats(vec![videos::VideoFormat::new(
video_url,
"1080".to_string(),
"m3u8".to_string(),
)])
let votes = hit.likes + hit.dislikes;
let rating = match votes {
0 => 0_f32,
_ => (hit.likes as f32 / votes as f32) * 100_f32,
};
VideoItem::new(
hit.id.to_string(),
hit.name.clone(),
video_url,
"hanime".to_string(),
thumb,
duration,
)
.tags(hit.tags.clone())
.uploader(hit.brand.clone())
.views(hit.views as u32)
.rating(rating)
.aspect_ratio(0.68)
.formats(formats)
}
async fn fetch_stream_url(&self, id: &str, slug: &str, options: &ServerOptions) -> Result<String> {
let manifest_url = format!(
"https://cached.freeanimehentai.net/api/v8/guest/videos/{id}/manifest"
);
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let payload = json!({ "width": 571, "height": 703, "ab": "kh" });
let _ = requester
.post_json(
&format!(
"https://cached.freeanimehentai.net/api/v8/hentai_videos/{slug}/play"
),
&payload,
vec![
("Origin".to_string(), "https://hanime.tv".to_string()),
("Referer".to_string(), "https://hanime.tv/".to_string()),
],
)
.await;
ntex::time::sleep(ntex::time::Seconds(1)).await;
let text = requester
.get_raw_with_headers(
&manifest_url,
vec![
("Origin".to_string(), "https://hanime.tv".to_string()),
("Referer".to_string(), "https://hanime.tv/".to_string()),
],
)
.await
.map_err(|e| {
report_provider_error_background(
"hanime",
"fetch_stream_url.get_raw_with_headers",
&e.to_string(),
);
Error::from(format!("Failed to fetch manifest: {e}"))
})?
.text()
.await
.map_err(|e| {
report_provider_error_background(
"hanime",
"fetch_stream_url.response_text",
&e.to_string(),
);
Error::from(format!("Failed to decode manifest body: {e}"))
})?;
if text.contains("Unautho") {
return Err(Error::from("Unauthorized"));
}
let urls_section = text
.split("streams")
.nth(1)
.ok_or_else(|| Error::from("Missing streams section in manifest"))?;
let mut url_vec = vec![];
for el in urls_section.split("\"url\":\"") {
let url = el.split('"').next().unwrap_or_default();
if !url.is_empty() && url.contains("m3u8") {
url_vec.push(url.to_string());
/// The whole catalogue in one document, memoised for an hour to match the CDN.
async fn fetch_index(&self, options: &ServerOptions) -> Result<Arc<Vec<HanimeSearchResult>>> {
let cell = INDEX_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cell.lock() {
if let Some((fetched_at, items)) = guard.as_ref() {
if fetched_at.elapsed().unwrap_or_default().as_secs() < INDEX_TTL_SECS {
return Ok(items.clone());
}
}
}
url_vec
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let response = requester
.get_raw_with_headers(
INDEX_URL,
vec![
("Origin".to_string(), SITE_ORIGIN.to_string()),
("Referer".to_string(), format!("{SITE_ORIGIN}/")),
("Accept".to_string(), "application/json".to_string()),
],
)
.await
.map_err(|e| Error::from(format!("Failed to fetch hanime index: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::from(format!("hanime index returned HTTP {status}")));
}
let parsed: HanimeIndexResponse = response
.json()
.await
.map_err(|e| Error::from(format!("Failed to parse hanime index: {e}")))?;
if parsed.data.is_empty() {
return Err(Error::from("hanime index was empty"));
}
let items = Arc::new(parsed.data);
if let Ok(mut guard) = cell.lock() {
*guard = Some((SystemTime::now(), items.clone()));
}
Ok(items)
}
/// Returns the free `sources` for a slug, best quality first. The auth host
/// occasionally refuses one of a burst of parallel connections, so retry once.
async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
match self.handshake(slug, options).await {
Ok(sources) => Ok(sources),
Err(_) => {
ntex::time::sleep(ntex::time::Millis(500)).await;
self.handshake(slug, options).await
}
}
}
async fn handshake(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let token = seal_envelope(
json!({
"timestamp_unix": timestamp,
"directive": "htv_player_handshake",
"slug": slug,
})
.to_string()
.as_bytes(),
)?;
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let response = requester
.post_json(
HANDSHAKE_URL,
&json!({ "token": token }),
vec![
("Origin".to_string(), SITE_ORIGIN.to_string()),
("Referer".to_string(), format!("{SITE_ORIGIN}/")),
("Accept".to_string(), "application/json".to_string()),
("x-signature-version".to_string(), "web2".to_string()),
("x-signature".to_string(), signature_for(timestamp)),
("x-time".to_string(), timestamp.to_string()),
],
)
.await
.map_err(|e| Error::from(format!("handshake request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::from(format!("handshake returned HTTP {status}")));
}
// The interesting part of the answer travels in a response header, not the body.
let encrypted = response
.headers()
.get("x-token")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_string())
.ok_or_else(|| Error::from("handshake response is missing x-token"))?;
let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?)
.map_err(|e| Error::from(format!("Failed to parse handshake payload: {e}")))?;
let mut sources: Vec<HanimeSource> = payload
.sources
.into_iter()
.next()
.ok_or_else(|| Error::from("No stream URL found in manifest"))
.filter(|s| s.kind == "normal" && !s.src.is_empty())
.collect();
sources.sort_by(|a, b| b.height.cmp(&a.height));
match sources.is_empty() {
true => Err(Error::from("handshake returned no playable sources")),
false => Ok(sources),
}
}
fn source_url(source: &HanimeSource) -> String {
match source.src.starts_with("http") {
true => source.src.clone(),
false => format!("{SITE_ORIGIN}{}", source.src),
}
}
fn quality_label(source: &HanimeSource) -> String {
match source.label.trim_end_matches('p') {
"" => source.height.to_string(),
label => label.to_string(),
}
}
/// The catalogue no longer carries a duration, so derive it from the playlist.
/// Doubles as proof that the URL we are about to hand out actually serves 200.
async fn fetch_duration(&self, url: &str, options: &ServerOptions) -> Result<u32> {
let mut requester =
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
let response = requester
.get_raw(url)
.await
.map_err(|e| Error::from(format!("Failed to fetch playlist: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::from(format!("playlist returned HTTP {status}")));
}
let playlist = response
.text()
.await
.map_err(|e| Error::from(format!("Failed to decode playlist: {e}")))?;
let seconds: f64 = playlist
.lines()
.filter_map(|line| line.strip_prefix("#EXTINF:"))
.filter_map(|value| value.split(',').next())
.filter_map(|value| value.trim().parse::<f64>().ok())
.sum();
Ok(seconds.round() as u32)
}
async fn get_video_item(
@@ -282,42 +439,69 @@ impl HanimeProvider {
pool: DbPool,
options: ServerOptions,
) -> Result<VideoItem> {
let id = hit.id.to_string();
let title = hit.name;
let thumb = crate::providers::build_proxy_url(
&options,
"hanime-cdn",
&crate::providers::strip_url_scheme(&hit.cover_url),
);
let duration = (hit.duration_in_ms / 1000) as u32;
let channel = "hanime".to_string();
let db_key = Self::db_key(&hit.slug);
match self.fetch_stream_url(&id, &hit.slug, &options).await {
Ok(stream_url) => {
match self.fetch_sources(&hit.slug, &options).await {
Ok(sources) => {
let formats: Vec<videos::VideoFormat> = sources
.iter()
.map(|source| {
videos::VideoFormat::new(
Self::source_url(source),
Self::quality_label(source),
"m3u8".to_string(),
)
})
.collect();
let stream_url = Self::source_url(&sources[0]);
let duration = self
.fetch_duration(&stream_url, &options)
.await
.unwrap_or_default();
if let Ok(mut conn) = pool.get() {
let _ = db::insert_video(&mut conn, &db_key, &stream_url);
}
return Ok(Self::build_video_item(
id, title, stream_url, channel, thumb, duration,
hit.tags, hit.brand, hit.views, hit.likes, hit.dislikes,
&hit,
stream_url,
thumb,
duration,
formats,
));
}
Err(e) => {
report_provider_error_background("hanime", "get_video_item.fetch_stream_url", &e.to_string());
report_provider_error_background(
"hanime",
"get_video_item.fetch_sources",
&format!("slug={}; error={e}", hit.slug),
);
}
}
// API failed — fall back to DB
let db_result = pool.get().ok().and_then(|mut conn| {
db::get_video(&mut conn, db_key.clone()).ok().flatten()
});
let db_result = pool
.get()
.ok()
.and_then(|mut conn| db::get_video(&mut conn, db_key.clone()).ok().flatten());
match db_result {
Some(video_url) if video_url != "https://streamable.cloud/hls/stream.m3u8" => {
Some(video_url) if video_url.contains("m3u8") || video_url.contains("/hls/") => {
let duration = self
.fetch_duration(&video_url, &options)
.await
.unwrap_or_default();
let formats = vec![videos::VideoFormat::new(
video_url.clone(),
"720".to_string(),
"m3u8".to_string(),
)];
Ok(Self::build_video_item(
id, title, video_url, channel, thumb, duration,
hit.tags, hit.brand, hit.views, hit.likes, hit.dislikes,
&hit, video_url, thumb, duration, formats,
))
}
Some(_) => {
@@ -330,67 +514,60 @@ impl HanimeProvider {
}
}
fn matches_query(hit: &HanimeSearchResult, query: &str) -> bool {
if query.is_empty() {
return true;
}
hit.name.to_lowercase().contains(query)
|| hit.search_titles.to_lowercase().contains(query)
|| hit.brand.to_lowercase().contains(query)
|| hit.tags.iter().any(|tag| tag.to_lowercase().contains(query))
}
fn sort_hits(hits: &mut [HanimeSearchResult], order_by: &str, ordering: &str) {
match order_by {
"title_sortable" => hits.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"views" => hits.sort_by(|a, b| a.views.cmp(&b.views)),
"likes" => hits.sort_by(|a, b| a.likes.cmp(&b.likes)),
"released_at_unix" => hits.sort_by(|a, b| a.released_at_unix.cmp(&b.released_at_unix)),
_ => hits.sort_by(|a, b| a.created_at_unix.cmp(&b.created_at_unix)),
}
if ordering != "asc" {
hits.reverse();
}
}
async fn get(
&self,
cache: VideoCache,
pool: DbPool,
page: u8,
page: u32,
per_page: usize,
query: String,
sort: String,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let index = format!("hanime:{}:{}:{}", query, page, sort);
let order_by = match sort.contains(".") {
true => sort
.split(".")
.collect::<Vec<&str>>()
.get(0)
.copied()
.unwrap_or_default()
.to_string(),
false => "created_at_unix".to_string(),
};
let ordering = match sort.contains(".") {
true => sort
.split(".")
.collect::<Vec<&str>>()
.get(1)
.copied()
.unwrap_or_default()
.to_string(),
false => "desc".to_string(),
let index = format!("hanime:{query}:{page}:{per_page}:{sort}");
let (order_by, ordering) = match sort.split_once('.') {
Some((order_by, ordering)) => (order_by.to_string(), ordering.to_string()),
None => ("created_at_unix".to_string(), "desc".to_string()),
};
let old_items = match cache.get(&index) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 1 {
//println!("Cache hit for URL: {}", index);
return Ok(items.clone());
} else {
items.clone()
}
items.clone()
}
None => {
vec![]
}
None => vec![],
};
let search = HanimeSearchRequest::new()
.page(page - 1)
.search_text(query.clone())
.order_by(order_by)
.ordering(ordering);
let mut requester =
crate::providers::requester_or_default(&options, module_path!(), "missing_requester");
let response = match requester
.post_json("https://search.htv-services.com/search", &search, vec![])
.await
{
Ok(response) => response,
let catalogue = match self.fetch_index(&options).await {
Ok(catalogue) => catalogue,
Err(e) => {
report_provider_error(
"hanime",
"get.search_request",
"get.fetch_index",
&format!("query={query}; page={page}; error={e}"),
)
.await;
@@ -398,27 +575,32 @@ impl HanimeProvider {
}
};
let hits = match response.json::<HanimeSearchResponse>().await {
Ok(resp) => resp.hits,
Err(e) => {
println!("Failed to parse HanimeSearchResponse: {}", e);
return Ok(old_items);
}
};
let hits_json: Vec<HanimeSearchResult> = serde_json::from_str(hits.as_str())
.map_err(|e| format!("Failed to parse hits JSON: {}", e))?;
// let timeout_duration = Duration::from_secs(120);
let futures = hits_json
// The upstream dump ignores every query parameter, so search, sort and
// pagination are applied here.
let needle = query.trim().to_lowercase();
let mut hits: Vec<HanimeSearchResult> = catalogue
.iter()
.filter(|hit| Self::matches_query(hit, &needle))
.cloned()
.collect();
Self::sort_hits(&mut hits, &order_by, &ordering);
let offset = (page.saturating_sub(1) as usize).saturating_mul(per_page);
let hits: Vec<HanimeSearchResult> = hits.into_iter().skip(offset).take(per_page).collect();
if hits.is_empty() {
return Ok(vec![]);
}
let futures = hits
.into_iter()
.map(|el| self.get_video_item(el.clone(), pool.clone(), options.clone()));
.map(|el| self.get_video_item(el, pool.clone(), options.clone()));
let results: Vec<Result<VideoItem>> = join_all(futures).await;
let video_items: Vec<VideoItem> = results.into_iter().filter_map(Result::ok).collect();
if !video_items.is_empty() {
cache.remove(&index);
cache.insert(index.clone(), video_items.clone());
} else {
if video_items.is_empty() {
return Ok(old_items);
}
cache.remove(&index);
cache.insert(index.clone(), video_items.clone());
Ok(video_items)
}
@@ -436,33 +618,17 @@ impl Provider for HanimeProvider {
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = options;
let _ = per_page;
let _ = sort;
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
Some(q) => {
self.get(
cache,
pool,
page.parse::<u8>().unwrap_or(1),
q,
sort,
options,
)
.await
}
None => {
self.get(
cache,
pool,
page.parse::<u8>().unwrap_or(1),
"".to_string(),
sort,
options,
)
.await
}
};
let videos = self
.get(
cache,
pool,
page.parse::<u32>().unwrap_or(1).max(1),
per_page.parse::<usize>().unwrap_or(20).clamp(1, 100),
query.unwrap_or_default(),
sort,
options,
)
.await;
match videos {
Ok(v) => v,
Err(e) => {