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; use crate::api::ClientVersion; use crate::db; use crate::providers::{Provider, report_provider_error, report_provider_error_background}; use crate::status::*; use crate::util::cache::VideoCache; use crate::videos::{self, ServerOptions, VideoItem}; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { group_id: "hentai-animation", tags: &["hentai", "anime", "premium"], }; error_chain! { foreign_links { Io(std::io::Error); HttpRequest(wreq::Error); } } /// 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; type IndexCache = OnceLock>)>>>; static INDEX_CACHE: IndexCache = OnceLock::new(); #[derive(serde::Deserialize, Debug)] struct HanimeIndexResponse { data: Vec, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] struct HanimeSearchResult { id: u64, name: String, #[serde(default)] search_titles: String, slug: String, #[serde(default)] views: u64, cover_url: String, #[serde(default)] brand: String, #[serde(default)] likes: u64, #[serde(default)] dislikes: u64, #[serde(default)] tags: Vec, #[serde(default)] created_at_unix: u64, #[serde(default)] released_at_unix: u64, } #[derive(serde::Deserialize, Debug)] struct HanimeHandshakePayload { #[serde(default)] sources: Vec, } #[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> { 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::::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 { 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> { 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> { 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)] pub struct HanimeProvider; impl HanimeProvider { pub fn new() -> Self { HanimeProvider } fn build_channel(&self, _clientversion: ClientVersion) -> Channel { Channel { id: "hanime".to_string(), name: "Hanime".to_string(), description: "Free Hentai from Hanime".to_string(), premium: false, favicon: "https://www.google.com/s2/favicons?sz=64&domain=hanime.tv".to_string(), status: "active".to_string(), categories: vec![], options: vec![ChannelOption { id: "sort".to_string(), title: "Sort".to_string(), description: "Sort the Videos".to_string(), systemImage: "list.number".to_string(), colorName: "blue".to_string(), options: vec![ FilterOption { id: "created_at_unix.desc".to_string(), title: "Recent Upload".to_string(), }, FilterOption { id: "created_at_unix.asc".to_string(), title: "Old Upload".to_string(), }, FilterOption { id: "views.desc".to_string(), title: "Most Views".to_string(), }, FilterOption { id: "views.asc".to_string(), title: "Least Views".to_string(), }, FilterOption { id: "likes.desc".to_string(), title: "Most Likes".to_string(), }, FilterOption { id: "likes.asc".to_string(), title: "Least Likes".to_string(), }, FilterOption { id: "released_at_unix.desc".to_string(), title: "New".to_string(), }, FilterOption { id: "released_at_unix.asc".to_string(), title: "Old".to_string(), }, FilterOption { id: "title_sortable.asc".to_string(), title: "A - Z".to_string(), }, FilterOption { id: "title_sortable.desc".to_string(), title: "Z - A".to_string(), }, ], multiSelect: false, }], nsfw: true, cacheDuration: None, ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()), } } fn db_key(slug: &str) -> String { format!("https://hanime.tv/videos/hentai/{slug}") } fn build_video_item( hit: &HanimeSearchResult, video_url: String, thumb: String, duration: u32, formats: Vec, ) -> VideoItem { 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) } /// The whole catalogue in one document, memoised for an hour to match the CDN. async fn fetch_index(&self, options: &ServerOptions) -> Result>> { 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()); } } } 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> { 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> { 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 = payload .sources .into_iter() .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 { 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::().ok()) .sum(); Ok(seconds.round() as u32) } async fn get_video_item( &self, hit: HanimeSearchResult, pool: DbPool, options: ServerOptions, ) -> Result { let thumb = crate::providers::build_proxy_url( &options, "hanime-cdn", &crate::providers::strip_url_scheme(&hit.cover_url), ); let db_key = Self::db_key(&hit.slug); match self.fetch_sources(&hit.slug, &options).await { Ok(sources) => { let formats: Vec = 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( &hit, stream_url, thumb, duration, formats, )); } Err(e) => { 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()); match db_result { 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( &hit, video_url, thumb, duration, formats, )) } Some(_) => { if let Ok(mut conn) = pool.get() { let _ = db::delete_video(&mut conn, db_key); } Err(Error::from("Stale DB entry and API unavailable")) } None => Err(Error::from("API unavailable and no DB fallback")), } } 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: u32, per_page: usize, query: String, sort: String, options: ServerOptions, ) -> Result> { 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 { return Ok(items.clone()); } items.clone() } None => vec![], }; let catalogue = match self.fetch_index(&options).await { Ok(catalogue) => catalogue, Err(e) => { report_provider_error( "hanime", "get.fetch_index", &format!("query={query}; page={page}; error={e}"), ) .await; return Ok(old_items); } }; // 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 = 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 = 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, pool.clone(), options.clone())); let results: Vec> = join_all(futures).await; let video_items: Vec = results.into_iter().filter_map(Result::ok).collect(); if video_items.is_empty() { return Ok(old_items); } cache.remove(&index); cache.insert(index.clone(), video_items.clone()); Ok(video_items) } } #[async_trait] impl Provider for HanimeProvider { async fn get_videos( &self, cache: VideoCache, pool: DbPool, sort: String, query: Option, page: String, per_page: String, options: ServerOptions, ) -> Vec { let videos = self .get( cache, pool, page.parse::().unwrap_or(1).max(1), per_page.parse::().unwrap_or(20).clamp(1, 100), query.unwrap_or_default(), sort, options, ) .await; match videos { Ok(v) => v, Err(e) => { println!("Error fetching videos: {}", e); vec![] } } } fn get_channel(&self, clientversion: ClientVersion) -> Option { Some(self.build_channel(clientversion)) } }