hanime: play through a redirect proxy; the signed stream links expire
Items now carry /proxy/hanime/{slug}/{height}.m3u8, which does a fresh
handshake per play and 302s to the current playlist. The handshake moved
to util/hanime_resolve.rs so single-provider builds keep the route.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
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::time::SystemTime;
|
||||
use std::vec;
|
||||
|
||||
use crate::DbPool;
|
||||
@@ -16,6 +11,7 @@ use crate::db;
|
||||
use crate::providers::{Provider, report_provider_error, report_provider_error_background};
|
||||
use crate::status::*;
|
||||
use crate::util::cache::VideoCache;
|
||||
use crate::util::hanime_resolve::{self as resolve, HanimeSource, SITE_ORIGIN};
|
||||
use crate::videos::{self, ServerOptions, VideoItem};
|
||||
|
||||
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
|
||||
@@ -34,14 +30,6 @@ error_chain! {
|
||||
/// 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<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>;
|
||||
@@ -76,99 +64,6 @@ struct HanimeSearchResult {
|
||||
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)]
|
||||
pub struct HanimeProvider;
|
||||
|
||||
@@ -242,6 +137,25 @@ impl HanimeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh free sources for a slug, best quality first.
|
||||
async fn fetch_sources(&self, slug: &str, options: &ServerOptions) -> Result<Vec<HanimeSource>> {
|
||||
let mut requester =
|
||||
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
|
||||
resolve::fetch_sources(&mut requester, slug)
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// The stream URLs expire, so players get a stable redirect that does a fresh
|
||||
/// handshake on every play instead of the signed link itself.
|
||||
fn proxied_url(options: &ServerOptions, slug: &str, source: &HanimeSource) -> String {
|
||||
crate::providers::build_proxy_url(
|
||||
options,
|
||||
"hanime",
|
||||
&format!("{slug}/{}.m3u8", source.height),
|
||||
)
|
||||
}
|
||||
|
||||
fn db_key(slug: &str) -> String {
|
||||
format!("https://hanime.tv/videos/hentai/{slug}")
|
||||
}
|
||||
@@ -319,86 +233,6 @@ impl HanimeProvider {
|
||||
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()
|
||||
.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(),
|
||||
@@ -452,15 +286,15 @@ impl HanimeProvider {
|
||||
.iter()
|
||||
.map(|source| {
|
||||
videos::VideoFormat::new(
|
||||
Self::source_url(source),
|
||||
Self::proxied_url(&options, &hit.slug, source),
|
||||
Self::quality_label(source),
|
||||
"m3u8".to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let stream_url = Self::source_url(&sources[0]);
|
||||
let stream_url = Self::proxied_url(&options, &hit.slug, &sources[0]);
|
||||
let duration = self
|
||||
.fetch_duration(&stream_url, &options)
|
||||
.fetch_duration(&resolve::source_url(&sources[0]), &options)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Ok(mut conn) = pool.get() {
|
||||
@@ -490,11 +324,8 @@ impl HanimeProvider {
|
||||
.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();
|
||||
Some(video_url) if video_url.contains("/proxy/hanime/") => {
|
||||
let duration = 0;
|
||||
let formats = vec![videos::VideoFormat::new(
|
||||
video_url.clone(),
|
||||
"720".to_string(),
|
||||
|
||||
71
src/proxies/hanime.rs
Normal file
71
src/proxies/hanime.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use ntex::http::Method;
|
||||
use ntex::http::header::LOCATION;
|
||||
use ntex::web::{self, HttpRequest};
|
||||
|
||||
use crate::util::hanime_resolve::{fetch_sources, pick_source, source_url};
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
/// `{slug}[/{height}][.m3u8]`.
|
||||
fn parse_endpoint(endpoint: &str) -> Option<(String, Option<u32>)> {
|
||||
let endpoint = endpoint.trim_matches('/').trim_end_matches(".m3u8");
|
||||
let mut parts = endpoint.split('/');
|
||||
let slug = parts
|
||||
.next()
|
||||
.filter(|slug| {
|
||||
!slug.is_empty()
|
||||
&& slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
})?;
|
||||
let height = parts.next().and_then(|h| h.parse::<u32>().ok());
|
||||
Some((slug.to_string(), height))
|
||||
}
|
||||
|
||||
/// hanime's stream links expire, so the channel hands out this stable URL and a
|
||||
/// fresh handshake happens on every play: redirects to the current playlist.
|
||||
pub async fn redirect_to_media(
|
||||
req: HttpRequest,
|
||||
requester: web::types::State<Requester>,
|
||||
) -> Result<impl web::Responder, web::Error> {
|
||||
let endpoint = req.match_info().query("endpoint").to_string();
|
||||
let Some((slug, height)) = parse_endpoint(&endpoint) else {
|
||||
return Ok(web::HttpResponse::BadRequest().finish());
|
||||
};
|
||||
if req.method() == Method::HEAD {
|
||||
return Ok(web::HttpResponse::Ok().finish());
|
||||
}
|
||||
|
||||
let mut requester = requester.get_ref().clone();
|
||||
let Ok(sources) = fetch_sources(&mut requester, &slug).await else {
|
||||
return Ok(web::HttpResponse::BadGateway().finish());
|
||||
};
|
||||
let Some(source) = pick_source(&sources, height) else {
|
||||
return Ok(web::HttpResponse::NotFound().finish());
|
||||
};
|
||||
Ok(web::HttpResponse::Found()
|
||||
.header(LOCATION, source_url(source))
|
||||
.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_endpoint;
|
||||
|
||||
#[test]
|
||||
fn parses_slug_and_height() {
|
||||
assert_eq!(
|
||||
parse_endpoint("natsu-zuma-2/720.m3u8"),
|
||||
Some(("natsu-zuma-2".to_string(), Some(720)))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_endpoint("/natsu-zuma-2"),
|
||||
Some(("natsu-zuma-2".to_string(), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_slugs() {
|
||||
assert_eq!(parse_endpoint("../etc/720.m3u8"), None);
|
||||
assert_eq!(parse_endpoint(""), None);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub mod clapdat;
|
||||
pub mod doodstream;
|
||||
pub mod eporner;
|
||||
pub mod fikfapthumb;
|
||||
pub mod hanime;
|
||||
pub mod hanimecdn;
|
||||
pub mod hanimethumb;
|
||||
pub mod heavyfetish;
|
||||
|
||||
@@ -157,6 +157,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::post().to(crate::proxies::fikfapthumb::get_image))
|
||||
.route(web::get().to(crate::proxies::fikfapthumb::get_image)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hanime/{endpoint}*")
|
||||
.route(web::get().to(crate::proxies::hanime::redirect_to_media))
|
||||
.route(web::head().to(crate::proxies::hanime::redirect_to_media)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/hanime-cdn/{endpoint}*")
|
||||
.route(web::post().to(crate::proxies::hanimecdn::get_image))
|
||||
|
||||
208
src/util/hanime_resolve.rs
Normal file
208
src/util/hanime_resolve.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! hanime.tv stream resolution, shared by the `hanime` channel and its
|
||||
//! `/proxy/hanime/` redirect proxy.
|
||||
//!
|
||||
//! Lives here (always compiled) rather than in `providers/hanime.rs` because
|
||||
//! `HOT_TUB_PROVIDER=<id>` single-provider builds only compile the selected
|
||||
//! provider, while the proxy routes are always registered.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use base64::Engine;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake";
|
||||
pub 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";
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
fn err(message: impl Into<String>) -> String {
|
||||
message.into()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct HanimeHandshakePayload {
|
||||
#[serde(default)]
|
||||
pub sources: Vec<HanimeSource>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct HanimeSource {
|
||||
#[serde(default)]
|
||||
pub src: String,
|
||||
#[serde(default)]
|
||||
pub height: u32,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
/// `"normal"` for the free streams, `"promotion"` for the premium-only teaser
|
||||
/// entries which always carry an empty `src`.
|
||||
#[serde(default)]
|
||||
pub 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| err(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| err(format!("handshake encrypt failed: {e}")))?;
|
||||
if sealed.len() < 16 {
|
||||
return Err(err("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| err(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(|| err(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| err(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()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn fetch_sources(requester: &mut Requester, slug: &str) -> Result<Vec<HanimeSource>> {
|
||||
match handshake(requester, slug).await {
|
||||
Ok(sources) => Ok(sources),
|
||||
Err(_) => {
|
||||
ntex::time::sleep(ntex::time::Millis(500)).await;
|
||||
handshake(requester, slug).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handshake(requester: &mut Requester, slug: &str) -> 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 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| err(format!("handshake request failed: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(err(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(|| err("handshake response is missing x-token"))?;
|
||||
|
||||
let payload: HanimeHandshakePayload = serde_json::from_slice(&open_envelope(&encrypted)?)
|
||||
.map_err(|e| err(format!("Failed to parse handshake payload: {e}")))?;
|
||||
|
||||
let mut sources: Vec<HanimeSource> = 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(err("handshake returned no playable sources")),
|
||||
false => Ok(sources),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_url(source: &HanimeSource) -> String {
|
||||
match source.src.starts_with("http") {
|
||||
true => source.src.clone(),
|
||||
false => format!("{SITE_ORIGIN}{}", source.src),
|
||||
}
|
||||
}
|
||||
|
||||
/// The source of the requested height, else the best one.
|
||||
pub fn pick_source(sources: &[HanimeSource], height: Option<u32>) -> Option<&HanimeSource> {
|
||||
height
|
||||
.and_then(|h| sources.iter().find(|s| s.height == h))
|
||||
.or_else(|| sources.first())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod discord;
|
||||
pub mod eporner_resolve;
|
||||
pub mod flaresolverr;
|
||||
pub mod flow_debug;
|
||||
pub mod hanime_resolve;
|
||||
pub mod hoster_proxy;
|
||||
pub mod proxy;
|
||||
pub mod requester;
|
||||
|
||||
Reference in New Issue
Block a user