From ee5e124056e24c9ae4f74903198848975fd67f50 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 7 Sep 2026 08:02:15 +0000 Subject: [PATCH] fixes --- Cargo.toml | 1 + src/api.rs | 15 + src/providers/hanime.rs | 656 +++++++++++++++++++++++-------------- src/providers/pimpbunny.rs | 2 +- src/providers/sxyprn.rs | 22 +- src/proxies/sxyprn.rs | 17 +- src/util/mod.rs | 29 ++ 7 files changed, 482 insertions(+), 260 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a9d0d56..0dc8a99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ pbkdf2 = { version = "0.12", features = ["hmac"] } hmac = "0.12" sha2 = "0.10" aes = "0.8" +aes-gcm = "0.10" cbc = { version = "0.1", features = ["alloc"] } hex = "0.4" chromiumoxide = { version = "0.7", features = ["tokio-runtime"] } diff --git a/src/api.rs b/src/api.rs index 7e63dd8..f95a182 100644 --- a/src/api.rs +++ b/src/api.rs @@ -27,6 +27,14 @@ pub struct 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 { ClientVersion { 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 { // Example input: "Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0 0.002478" let first_part = input.split_whitespace().next()?; diff --git a/src/providers/hanime.rs b/src/providers/hanime.rs index 244fcb5..f807f4d 100644 --- a/src/providers/hanime.rs +++ b/src/providers/hanime.rs @@ -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, - tags_mode: String, - brands: Vec, - blacklist: Vec, - 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>)>>>; +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, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] struct HanimeSearchResult { id: u64, name: String, - titles: Vec, + #[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, + #[serde(default)] likes: u64, + #[serde(default)] dislikes: u64, - downloads: u64, - monthly_ranked: Option, + #[serde(default)] tags: Vec, - 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, +} + +#[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)] @@ -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, - brand: String, - views: u64, - likes: u64, - dislikes: u64, + formats: Vec, ) -> 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 { - 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>> { + 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> { + 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() - .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 { + 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( @@ -282,42 +439,69 @@ impl HanimeProvider { pool: DbPool, options: ServerOptions, ) -> Result { - 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 = 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> { - let index = format!("hanime:{}:{}:{}", query, page, sort); - let order_by = match sort.contains(".") { - true => sort - .split(".") - .collect::>() - .get(0) - .copied() - .unwrap_or_default() - .to_string(), - false => "created_at_unix".to_string(), - }; - let ordering = match sort.contains(".") { - true => sort - .split(".") - .collect::>() - .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::().await { - Ok(resp) => resp.hits, - Err(e) => { - println!("Failed to parse HanimeSearchResponse: {}", e); - return Ok(old_items); - } - }; - let hits_json: Vec = 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 = 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.clone(), pool.clone(), options.clone())); + .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() { - 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 { - let _ = options; - let _ = per_page; - let _ = sort; - let videos: std::result::Result, Error> = match query { - Some(q) => { - self.get( - cache, - pool, - page.parse::().unwrap_or(1), - q, - sort, - options, - ) - .await - } - None => { - self.get( - cache, - pool, - page.parse::().unwrap_or(1), - "".to_string(), - sort, - options, - ) - .await - } - }; + 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) => { diff --git a/src/providers/pimpbunny.rs b/src/providers/pimpbunny.rs index 28e0e26..7e063de 100644 --- a/src/providers/pimpbunny.rs +++ b/src/providers/pimpbunny.rs @@ -807,7 +807,7 @@ impl PimpbunnyProvider { ]; Ok( - VideoItem::new(id, title, video_url, "pimpbunny".into(), thumb, duration) + VideoItem::new(id, title, proxy_url, "pimpbunny".into(), thumb, duration) .formats(formats) .preview(preview) .views(views), diff --git a/src/providers/sxyprn.rs b/src/providers/sxyprn.rs index e7eefcd..e0bdfb4 100644 --- a/src/providers/sxyprn.rs +++ b/src/providers/sxyprn.rs @@ -148,7 +148,7 @@ impl SxyprnProvider { let is_app_client = options .client_version .as_ref() - .map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string())) + .map(ClientVersion::is_verified_hottub) .unwrap_or(false); let cache_key = if is_app_client { format!("{url_str}#app") @@ -246,7 +246,7 @@ impl SxyprnProvider { let is_app_client = options .client_version .as_ref() - .map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string())) + .map(ClientVersion::is_verified_hottub) .unwrap_or(false); let cache_key = if is_app_client { format!("{url_str}#app") @@ -325,14 +325,14 @@ impl SxyprnProvider { return Ok(vec![]); } - // The Hottub app can resolve directly-playable format URLs itself, so for - // app requests we serve the real sxyprn.com page as `url` and eagerly - // resolve every mirror CDN URL into `formats`. Other clients keep the - // lazy `/proxy/sxyprn/post/{id}` redirect and get no formats. + // Verified Hottub clients get every mirror CDN URL eagerly resolved to a + // direct media URL in `formats`. Other clients get no formats. Both keep + // the `/proxy/sxyprn/post/{id}` redirect as `url`, since sxyprn media is + // only reachable through server-side resolution. let is_app_client = options .client_version .as_ref() - .map(|cv| *cv == ClientVersion::new(0, 0, "Hot%20Tub".to_string())) + .map(ClientVersion::is_verified_hottub) .unwrap_or(false); // take content before "