fixes
This commit is contained in:
@@ -41,6 +41,7 @@ pbkdf2 = { version = "0.12", features = ["hmac"] }
|
|||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
|
aes-gcm = "0.10"
|
||||||
cbc = { version = "0.1", features = ["alloc"] }
|
cbc = { version = "0.1", features = ["alloc"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
|
chromiumoxide = { version = "0.7", features = ["tokio-runtime"] }
|
||||||
|
|||||||
15
src/api.rs
15
src/api.rs
@@ -27,6 +27,14 @@ pub struct ClientVersion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ClientVersion {
|
impl ClientVersion {
|
||||||
|
/// Client name carried by the Hot Tub app.
|
||||||
|
pub const HOTTUB_NAME: &'static str = "Hot%20Tub";
|
||||||
|
|
||||||
|
/// Version stamped on a request whose `User-Agent` was missing or did not
|
||||||
|
/// parse. Such a request is given the Hot Tub name as a default, so the
|
||||||
|
/// name alone does not prove the client is really the app.
|
||||||
|
pub const UNKNOWN_VERSION: u32 = 999;
|
||||||
|
|
||||||
pub fn new(version: u32, subversion: u32, name: String) -> ClientVersion {
|
pub fn new(version: u32, subversion: u32, name: String) -> ClientVersion {
|
||||||
ClientVersion {
|
ClientVersion {
|
||||||
version,
|
version,
|
||||||
@@ -35,6 +43,13 @@ impl ClientVersion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True only for a client that identified itself as Hot Tub with a real
|
||||||
|
/// version. Requests that fell back to [`Self::UNKNOWN_VERSION`] are
|
||||||
|
/// explicitly excluded, since their name was assumed rather than sent.
|
||||||
|
pub fn is_verified_hottub(&self) -> bool {
|
||||||
|
self.name == Self::HOTTUB_NAME && self.version != Self::UNKNOWN_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse(input: &str) -> Option<Self> {
|
pub fn parse(input: &str) -> Option<Self> {
|
||||||
// Example input: "Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0 0.002478"
|
// Example input: "Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0 0.002478"
|
||||||
let first_part = input.split_whitespace().next()?;
|
let first_part = input.split_whitespace().next()?;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use base64::Engine;
|
||||||
use error_chain::error_chain;
|
use error_chain::error_chain;
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use std::vec;
|
use std::vec;
|
||||||
|
|
||||||
use crate::DbPool;
|
use crate::DbPool;
|
||||||
@@ -25,81 +31,142 @@ error_chain! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
/// Full catalogue dump. Ignores every query parameter, so filtering, sorting and
|
||||||
struct HanimeSearchRequest {
|
/// pagination all happen client side. ~860 KB compressed, CDN cached for an hour.
|
||||||
search_text: String,
|
const INDEX_URL: &str = "https://guest.freeanimehentai.net/api/v11/search_hvs";
|
||||||
tags: Vec<String>,
|
const HANDSHAKE_URL: &str = "https://auth.hanime.tv/api/v11/handshake";
|
||||||
tags_mode: String,
|
const SITE_ORIGIN: &str = "https://hanime.tv";
|
||||||
brands: Vec<String>,
|
/// Envelope encryption used for the handshake request/response bodies.
|
||||||
blacklist: Vec<String>,
|
const HANDSHAKE_KEY_SEED: &[u8] = b"htv-insecure-handshake-v1";
|
||||||
order_by: String,
|
const HANDSHAKE_AAD: &[u8] = b"htv-insecure-v1";
|
||||||
ordering: String,
|
/// Pieces of the `x-signature` pre-image, as baked into the site's wasm module.
|
||||||
page: u8,
|
const SIGNATURE_SECRET: &str = "Xkdi29";
|
||||||
}
|
const SIGNATURE_SALT: &str = "mn2";
|
||||||
|
const INDEX_TTL_SECS: u64 = 3600;
|
||||||
|
|
||||||
impl HanimeSearchRequest {
|
type IndexCache = OnceLock<Mutex<Option<(SystemTime, Arc<Vec<HanimeSearchResult>>)>>>;
|
||||||
pub fn new() -> Self {
|
static INDEX_CACHE: IndexCache = OnceLock::new();
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug)]
|
#[derive(serde::Deserialize, Debug)]
|
||||||
struct HanimeSearchResponse {
|
struct HanimeIndexResponse {
|
||||||
page: u8,
|
data: Vec<HanimeSearchResult>,
|
||||||
nbPages: u8,
|
|
||||||
nbHits: u32,
|
|
||||||
hitsPerPage: u8,
|
|
||||||
hits: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
struct HanimeSearchResult {
|
struct HanimeSearchResult {
|
||||||
id: u64,
|
id: u64,
|
||||||
name: String,
|
name: String,
|
||||||
titles: Vec<String>,
|
#[serde(default)]
|
||||||
|
search_titles: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
description: String,
|
#[serde(default)]
|
||||||
views: u64,
|
views: u64,
|
||||||
interests: u64,
|
|
||||||
poster_url: String,
|
|
||||||
cover_url: String,
|
cover_url: String,
|
||||||
|
#[serde(default)]
|
||||||
brand: String,
|
brand: String,
|
||||||
brand_id: u64,
|
#[serde(default)]
|
||||||
duration_in_ms: u32,
|
|
||||||
is_censored: bool,
|
|
||||||
rating: Option<u32>,
|
|
||||||
likes: u64,
|
likes: u64,
|
||||||
|
#[serde(default)]
|
||||||
dislikes: u64,
|
dislikes: u64,
|
||||||
downloads: u64,
|
#[serde(default)]
|
||||||
monthly_ranked: Option<u64>,
|
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
created_at: u64,
|
#[serde(default)]
|
||||||
released_at: u64,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -171,109 +238,199 @@ impl HanimeProvider {
|
|||||||
}],
|
}],
|
||||||
nsfw: true,
|
nsfw: true,
|
||||||
cacheDuration: None,
|
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 {
|
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(
|
fn build_video_item(
|
||||||
id: String,
|
hit: &HanimeSearchResult,
|
||||||
title: String,
|
|
||||||
video_url: String,
|
video_url: String,
|
||||||
channel: String,
|
|
||||||
thumb: String,
|
thumb: String,
|
||||||
duration: u32,
|
duration: u32,
|
||||||
tags: Vec<String>,
|
formats: Vec<videos::VideoFormat>,
|
||||||
brand: String,
|
|
||||||
views: u64,
|
|
||||||
likes: u64,
|
|
||||||
dislikes: u64,
|
|
||||||
) -> VideoItem {
|
) -> VideoItem {
|
||||||
VideoItem::new(id, title, video_url.clone(), channel, thumb, duration)
|
let votes = hit.likes + hit.dislikes;
|
||||||
.tags(tags)
|
let rating = match votes {
|
||||||
.uploader(brand)
|
0 => 0_f32,
|
||||||
.views(views as u32)
|
_ => (hit.likes as f32 / votes as f32) * 100_f32,
|
||||||
.rating((likes as f32 / (likes + dislikes) as f32) * 100_f32)
|
};
|
||||||
.aspect_ratio(0.68)
|
VideoItem::new(
|
||||||
.formats(vec![videos::VideoFormat::new(
|
hit.id.to_string(),
|
||||||
video_url,
|
hit.name.clone(),
|
||||||
"1080".to_string(),
|
video_url,
|
||||||
"m3u8".to_string(),
|
"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> {
|
/// The whole catalogue in one document, memoised for an hour to match the CDN.
|
||||||
let manifest_url = format!(
|
async fn fetch_index(&self, options: &ServerOptions) -> Result<Arc<Vec<HanimeSearchResult>>> {
|
||||||
"https://cached.freeanimehentai.net/api/v8/guest/videos/{id}/manifest"
|
let cell = INDEX_CACHE.get_or_init(|| Mutex::new(None));
|
||||||
);
|
if let Ok(guard) = cell.lock() {
|
||||||
let mut requester =
|
if let Some((fetched_at, items)) = guard.as_ref() {
|
||||||
crate::providers::requester_or_default(options, module_path!(), "missing_requester");
|
if fetched_at.elapsed().unwrap_or_default().as_secs() < INDEX_TTL_SECS {
|
||||||
let payload = json!({ "width": 571, "height": 703, "ab": "kh" });
|
return Ok(items.clone());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
.into_iter()
|
||||||
.next()
|
.filter(|s| s.kind == "normal" && !s.src.is_empty())
|
||||||
.ok_or_else(|| Error::from("No stream URL found in manifest"))
|
.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(
|
async fn get_video_item(
|
||||||
@@ -282,42 +439,69 @@ impl HanimeProvider {
|
|||||||
pool: DbPool,
|
pool: DbPool,
|
||||||
options: ServerOptions,
|
options: ServerOptions,
|
||||||
) -> Result<VideoItem> {
|
) -> Result<VideoItem> {
|
||||||
let id = hit.id.to_string();
|
|
||||||
let title = hit.name;
|
|
||||||
let thumb = crate::providers::build_proxy_url(
|
let thumb = crate::providers::build_proxy_url(
|
||||||
&options,
|
&options,
|
||||||
"hanime-cdn",
|
"hanime-cdn",
|
||||||
&crate::providers::strip_url_scheme(&hit.cover_url),
|
&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);
|
let db_key = Self::db_key(&hit.slug);
|
||||||
|
|
||||||
match self.fetch_stream_url(&id, &hit.slug, &options).await {
|
match self.fetch_sources(&hit.slug, &options).await {
|
||||||
Ok(stream_url) => {
|
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() {
|
if let Ok(mut conn) = pool.get() {
|
||||||
let _ = db::insert_video(&mut conn, &db_key, &stream_url);
|
let _ = db::insert_video(&mut conn, &db_key, &stream_url);
|
||||||
}
|
}
|
||||||
return Ok(Self::build_video_item(
|
return Ok(Self::build_video_item(
|
||||||
id, title, stream_url, channel, thumb, duration,
|
&hit,
|
||||||
hit.tags, hit.brand, hit.views, hit.likes, hit.dislikes,
|
stream_url,
|
||||||
|
thumb,
|
||||||
|
duration,
|
||||||
|
formats,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
// API failed — fall back to DB
|
||||||
let db_result = pool.get().ok().and_then(|mut conn| {
|
let db_result = pool
|
||||||
db::get_video(&mut conn, db_key.clone()).ok().flatten()
|
.get()
|
||||||
});
|
.ok()
|
||||||
|
.and_then(|mut conn| db::get_video(&mut conn, db_key.clone()).ok().flatten());
|
||||||
|
|
||||||
match db_result {
|
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(
|
Ok(Self::build_video_item(
|
||||||
id, title, video_url, channel, thumb, duration,
|
&hit, video_url, thumb, duration, formats,
|
||||||
hit.tags, hit.brand, hit.views, hit.likes, hit.dislikes,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some(_) => {
|
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(
|
async fn get(
|
||||||
&self,
|
&self,
|
||||||
cache: VideoCache,
|
cache: VideoCache,
|
||||||
pool: DbPool,
|
pool: DbPool,
|
||||||
page: u8,
|
page: u32,
|
||||||
|
per_page: usize,
|
||||||
query: String,
|
query: String,
|
||||||
sort: String,
|
sort: String,
|
||||||
options: ServerOptions,
|
options: ServerOptions,
|
||||||
) -> Result<Vec<VideoItem>> {
|
) -> Result<Vec<VideoItem>> {
|
||||||
let index = format!("hanime:{}:{}:{}", query, page, sort);
|
let index = format!("hanime:{query}:{page}:{per_page}:{sort}");
|
||||||
let order_by = match sort.contains(".") {
|
let (order_by, ordering) = match sort.split_once('.') {
|
||||||
true => sort
|
Some((order_by, ordering)) => (order_by.to_string(), ordering.to_string()),
|
||||||
.split(".")
|
None => ("created_at_unix".to_string(), "desc".to_string()),
|
||||||
.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 old_items = match cache.get(&index) {
|
let old_items = match cache.get(&index) {
|
||||||
Some((time, items)) => {
|
Some((time, items)) => {
|
||||||
if time.elapsed().unwrap_or_default().as_secs() < 1 {
|
if time.elapsed().unwrap_or_default().as_secs() < 1 {
|
||||||
//println!("Cache hit for URL: {}", index);
|
|
||||||
return Ok(items.clone());
|
return Ok(items.clone());
|
||||||
} else {
|
|
||||||
items.clone()
|
|
||||||
}
|
}
|
||||||
|
items.clone()
|
||||||
}
|
}
|
||||||
None => {
|
None => vec![],
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let search = HanimeSearchRequest::new()
|
let catalogue = match self.fetch_index(&options).await {
|
||||||
.page(page - 1)
|
Ok(catalogue) => catalogue,
|
||||||
.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,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
report_provider_error(
|
report_provider_error(
|
||||||
"hanime",
|
"hanime",
|
||||||
"get.search_request",
|
"get.fetch_index",
|
||||||
&format!("query={query}; page={page}; error={e}"),
|
&format!("query={query}; page={page}; error={e}"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -398,27 +575,32 @@ impl HanimeProvider {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let hits = match response.json::<HanimeSearchResponse>().await {
|
// The upstream dump ignores every query parameter, so search, sort and
|
||||||
Ok(resp) => resp.hits,
|
// pagination are applied here.
|
||||||
Err(e) => {
|
let needle = query.trim().to_lowercase();
|
||||||
println!("Failed to parse HanimeSearchResponse: {}", e);
|
let mut hits: Vec<HanimeSearchResult> = catalogue
|
||||||
return Ok(old_items);
|
.iter()
|
||||||
}
|
.filter(|hit| Self::matches_query(hit, &needle))
|
||||||
};
|
.cloned()
|
||||||
let hits_json: Vec<HanimeSearchResult> = serde_json::from_str(hits.as_str())
|
.collect();
|
||||||
.map_err(|e| format!("Failed to parse hits JSON: {}", e))?;
|
Self::sort_hits(&mut hits, &order_by, &ordering);
|
||||||
// let timeout_duration = Duration::from_secs(120);
|
|
||||||
let futures = hits_json
|
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()
|
.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 results: Vec<Result<VideoItem>> = join_all(futures).await;
|
||||||
let video_items: Vec<VideoItem> = results.into_iter().filter_map(Result::ok).collect();
|
let video_items: Vec<VideoItem> = results.into_iter().filter_map(Result::ok).collect();
|
||||||
if !video_items.is_empty() {
|
if video_items.is_empty() {
|
||||||
cache.remove(&index);
|
|
||||||
cache.insert(index.clone(), video_items.clone());
|
|
||||||
} else {
|
|
||||||
return Ok(old_items);
|
return Ok(old_items);
|
||||||
}
|
}
|
||||||
|
cache.remove(&index);
|
||||||
|
cache.insert(index.clone(), video_items.clone());
|
||||||
|
|
||||||
Ok(video_items)
|
Ok(video_items)
|
||||||
}
|
}
|
||||||
@@ -436,33 +618,17 @@ impl Provider for HanimeProvider {
|
|||||||
per_page: String,
|
per_page: String,
|
||||||
options: ServerOptions,
|
options: ServerOptions,
|
||||||
) -> Vec<VideoItem> {
|
) -> Vec<VideoItem> {
|
||||||
let _ = options;
|
let videos = self
|
||||||
let _ = per_page;
|
.get(
|
||||||
let _ = sort;
|
cache,
|
||||||
let videos: std::result::Result<Vec<VideoItem>, Error> = match query {
|
pool,
|
||||||
Some(q) => {
|
page.parse::<u32>().unwrap_or(1).max(1),
|
||||||
self.get(
|
per_page.parse::<usize>().unwrap_or(20).clamp(1, 100),
|
||||||
cache,
|
query.unwrap_or_default(),
|
||||||
pool,
|
sort,
|
||||||
page.parse::<u8>().unwrap_or(1),
|
options,
|
||||||
q,
|
)
|
||||||
sort,
|
.await;
|
||||||
options,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.get(
|
|
||||||
cache,
|
|
||||||
pool,
|
|
||||||
page.parse::<u8>().unwrap_or(1),
|
|
||||||
"".to_string(),
|
|
||||||
sort,
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match videos {
|
match videos {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ impl PimpbunnyProvider {
|
|||||||
];
|
];
|
||||||
|
|
||||||
Ok(
|
Ok(
|
||||||
VideoItem::new(id, title, video_url, "pimpbunny".into(), thumb, duration)
|
VideoItem::new(id, title, proxy_url, "pimpbunny".into(), thumb, duration)
|
||||||
.formats(formats)
|
.formats(formats)
|
||||||
.preview(preview)
|
.preview(preview)
|
||||||
.views(views),
|
.views(views),
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ impl SxyprnProvider {
|
|||||||
let is_app_client = options
|
let is_app_client = options
|
||||||
.client_version
|
.client_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string()))
|
.map(ClientVersion::is_verified_hottub)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let cache_key = if is_app_client {
|
let cache_key = if is_app_client {
|
||||||
format!("{url_str}#app")
|
format!("{url_str}#app")
|
||||||
@@ -246,7 +246,7 @@ impl SxyprnProvider {
|
|||||||
let is_app_client = options
|
let is_app_client = options
|
||||||
.client_version
|
.client_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string()))
|
.map(ClientVersion::is_verified_hottub)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let cache_key = if is_app_client {
|
let cache_key = if is_app_client {
|
||||||
format!("{url_str}#app")
|
format!("{url_str}#app")
|
||||||
@@ -325,14 +325,14 @@ impl SxyprnProvider {
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Hottub app can resolve directly-playable format URLs itself, so for
|
// Verified Hottub clients get every mirror CDN URL eagerly resolved to a
|
||||||
// app requests we serve the real sxyprn.com page as `url` and eagerly
|
// direct media URL in `formats`. Other clients get no formats. Both keep
|
||||||
// resolve every mirror CDN URL into `formats`. Other clients keep the
|
// the `/proxy/sxyprn/post/{id}` redirect as `url`, since sxyprn media is
|
||||||
// lazy `/proxy/sxyprn/post/{id}` redirect and get no formats.
|
// only reachable through server-side resolution.
|
||||||
let is_app_client = options
|
let is_app_client = options
|
||||||
.client_version
|
.client_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string()))
|
.map(ClientVersion::is_verified_hottub)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// take content before "<script async"
|
// take content before "<script async"
|
||||||
@@ -556,15 +556,15 @@ impl SxyprnProvider {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for ((video_item, slug), task) in
|
for (video_item, task) in items.iter_mut().zip(tasks) {
|
||||||
items.iter_mut().zip(slugs.iter()).zip(tasks)
|
// Only verified-live CDN URLs come back here; items that resolved
|
||||||
{
|
// to nothing keep the lazy `/proxy/sxyprn/post/{id}` redirect,
|
||||||
|
// which re-resolves at playback time.
|
||||||
let resolved_urls = task.await.unwrap_or_default();
|
let resolved_urls = task.await.unwrap_or_default();
|
||||||
if resolved_urls.is_empty() {
|
if resolved_urls.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
video_item.url = format!("{}/post/{}", self.url, slug);
|
|
||||||
video_item.formats = Some(
|
video_item.formats = Some(
|
||||||
resolved_urls
|
resolved_urls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ async fn race_cdn_urls(candidate_urls: Vec<String>) -> String {
|
|||||||
/// `race_cdn_urls`, this does not stop at the first success -- the mirrors
|
/// `race_cdn_urls`, this does not stop at the first success -- the mirrors
|
||||||
/// are redundant copies of the same stream, and callers that want to serve
|
/// are redundant copies of the same stream, and callers that want to serve
|
||||||
/// directly-playable format URLs need as many working ones as possible.
|
/// directly-playable format URLs need as many working ones as possible.
|
||||||
|
///
|
||||||
|
/// Each resolved URL is probed before it is returned: sxyprn happily issues a
|
||||||
|
/// signed redirect for media that the CDN then answers with 404, and a format
|
||||||
|
/// URL that is not a live 200 must never reach the client.
|
||||||
pub(crate) async fn resolve_all_cdn_urls(candidate_urls: Vec<String>) -> Vec<String> {
|
pub(crate) async fn resolve_all_cdn_urls(candidate_urls: Vec<String>) -> Vec<String> {
|
||||||
if candidate_urls.is_empty() {
|
if candidate_urls.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -116,10 +120,15 @@ pub(crate) async fn resolve_all_cdn_urls(candidate_urls: Vec<String>) -> Vec<Str
|
|||||||
.map(|cdn_url| {
|
.map(|cdn_url| {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
crate::util::get_redirect_location(&cdn_url)
|
let media_url = crate::util::get_redirect_location(&cdn_url)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|loc| format!("https:{}", loc))
|
.map(|loc| format!("https:{}", loc))?;
|
||||||
|
if crate::util::media_url_is_live(&media_url, Some("https://sxyprn.com/")) {
|
||||||
|
Some(media_url)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -128,7 +137,9 @@ pub(crate) async fn resolve_all_cdn_urls(candidate_urls: Vec<String>) -> Vec<Str
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
// Redirect lookup plus liveness probe are two sequential round trips, so
|
||||||
|
// this budget is wider than the single-hop one in `race_cdn_urls`.
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(25);
|
||||||
let mut resolved = Vec::new();
|
let mut resolved = Vec::new();
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
if let Ok(Ok(Some(url))) = tokio::time::timeout_at(deadline, handle).await {
|
if let Ok(Ok(Some(url))) = tokio::time::timeout_at(deadline, handle).await {
|
||||||
|
|||||||
@@ -58,6 +58,35 @@ pub fn interleave<T: Clone>(lists: &[Vec<T>]) -> Vec<T> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probes `url` with a tiny ranged GET and reports whether it actually serves
|
||||||
|
/// media. Some CDNs answer a signed URL with 404 even though the redirect that
|
||||||
|
/// produced it looked fine, so a URL is only trustworthy once it has been hit.
|
||||||
|
pub fn media_url_is_live(url: &str, referer: Option<&str>) -> bool {
|
||||||
|
let mut cmd = Command::new("curl");
|
||||||
|
cmd.arg("-s")
|
||||||
|
.arg("-o")
|
||||||
|
.arg("/dev/null")
|
||||||
|
.arg("-L")
|
||||||
|
.arg("--max-time")
|
||||||
|
.arg("15")
|
||||||
|
.arg("-r")
|
||||||
|
.arg("0-1")
|
||||||
|
.arg("-w")
|
||||||
|
.arg("%{http_code}");
|
||||||
|
if let Some(referer) = referer {
|
||||||
|
cmd.arg("-e").arg(referer);
|
||||||
|
}
|
||||||
|
let output = match cmd.arg(url).output() {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
if !output.status.success() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let code = String::from_utf8_lossy(&output.stdout);
|
||||||
|
matches!(code.trim(), "200" | "206")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_redirect_location(url: &str) -> Result<Option<String>, Box<dyn Error>> {
|
pub fn get_redirect_location(url: &str) -> Result<Option<String>, Box<dyn Error>> {
|
||||||
// 1. Execute curl:
|
// 1. Execute curl:
|
||||||
// -s: Silent (no progress bar)
|
// -s: Silent (no progress bar)
|
||||||
|
|||||||
Reference in New Issue
Block a user