diff --git a/docs/provider-playbook.md b/docs/provider-playbook.md index 1a5c657..ef3778e 100644 --- a/docs/provider-playbook.md +++ b/docs/provider-playbook.md @@ -26,6 +26,7 @@ These rules cut across every step below. They are intentionally grouped at the t ### Output Shape +- **Never put a redirecting URL in `formats[].url`**: the Hot Tub app cannot follow redirects there. Redirect proxies (`REDIRECT_PROXIES` in `src/proxy.rs`) are for `video.url`; `api.rs` moves such formats to `url` for verified app clients as a safety net. - Use `formats` for real media URLs (HLS, multi-quality, signed token), and keep `video.url` on a stable page URL. Never set `video.url` to a tokenized stream that needs a `Referer` to play — the `url` field has no header contract. - If thumbnails need a `Referer` and the player has no per-thumb header mechanism, proxy them via `/proxy/-thumb/...`. The same applies to manifests with relative URIs. - Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route. diff --git a/src/api.rs b/src/api.rs index f95a182..ca95893 100644 --- a/src/api.rs +++ b/src/api.rs @@ -714,6 +714,15 @@ async fn videos_post( .collect(); } + // The Hot Tub app cannot follow a redirect in `formats[].url`: for verified + // app clients, formats that point at a redirecting `/proxy/...` become the + // item's `url` instead. + if clientversion.is_verified_hottub() { + for video in video_items.iter_mut() { + move_redirect_formats_to_url(video); + } + } + if let Some(literal_query) = literal_query.as_deref() { #[cfg(feature = "debug")] let before = video_items.len(); @@ -794,6 +803,30 @@ async fn videos_post( Ok(web::HttpResponse::Ok().json(&videos)) } +/// Moves formats that point at a redirecting proxy to `video.url`. +/// +/// If direct (non-redirecting) formats remain they are kept and `url` is left +/// alone; otherwise `url` becomes the first (best-listed) redirecting proxy URL +/// and `formats` is dropped entirely. +pub(crate) fn move_redirect_formats_to_url(video: &mut VideoItem) { + let Some(formats) = video.formats.take() else { + return; + }; + let (redirecting, direct): (Vec<_>, Vec<_>) = formats + .into_iter() + .partition(|format| crate::proxy::is_redirect_proxy_url(&format.url)); + if redirecting.is_empty() { + video.formats = Some(direct); + return; + } + if direct.is_empty() { + video.url = redirecting[0].url.clone(); + video.formats = None; + } else { + video.formats = Some(direct); + } +} + async fn uploaders_post( uploader_request: web::types::Json, cache: web::types::State, @@ -1082,4 +1115,53 @@ mod tests { assert_eq!(item.uploader.as_deref(), Some("FreeusePorn")); assert_eq!(item.formats.as_ref().map(|formats| formats.len()), Some(2)); } + + fn item_with_formats(urls: &[&str]) -> VideoItem { + let mut item = VideoItem::new( + "id".to_string(), + "t".to_string(), + "https://site.example/watch/1".to_string(), + "c".to_string(), + "https://thumb".to_string(), + 10, + ); + item.formats = Some( + urls.iter() + .map(|u| crate::videos::VideoFormat::new(u.to_string(), "q".to_string(), "mp4".to_string())) + .collect(), + ); + item + } + + #[test] + fn redirect_only_formats_move_to_url() { + let mut item = item_with_formats(&[ + "http://h/proxy/hqporner/1080", + "http://h/proxy/hqporner/720", + ]); + move_redirect_formats_to_url(&mut item); + assert_eq!(item.url, "http://h/proxy/hqporner/1080"); + assert!(item.formats.is_none()); + } + + #[test] + fn direct_formats_are_kept_and_redirect_ones_dropped() { + let mut item = item_with_formats(&[ + "http://h/proxy/hqporner/1080", + "https://cdn.example/v.mp4", + ]); + move_redirect_formats_to_url(&mut item); + assert_eq!(item.url, "https://site.example/watch/1"); + let formats = item.formats.expect("direct format kept"); + assert_eq!(formats.len(), 1); + assert_eq!(formats[0].url, "https://cdn.example/v.mp4"); + } + + #[test] + fn media_proxy_formats_are_untouched() { + let mut item = item_with_formats(&["http://h/proxy/noodlemagazine/x.mp4"]); + move_redirect_formats_to_url(&mut item); + assert_eq!(item.url, "https://site.example/watch/1"); + assert_eq!(item.formats.map(|f| f.len()), Some(1)); + } } diff --git a/src/proxy.rs b/src/proxy.rs index 7b3b11b..80d2aa7 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -20,6 +20,40 @@ use crate::proxies::thaiporntv::ThaipornTvProxy; use crate::proxies::*; use crate::util::requester::Requester; +/// Proxies that answer with a `302` to the real media instead of streaming it. +/// The Hot Tub app cannot follow a redirect inside `formats[].url`, so for app +/// clients a format pointing at one of these is moved to `video.url` (see +/// `move_redirect_formats_to_url`). Keep in sync with the `proxy2redirect` +/// routes below; `redirect_proxy_list_matches_routes` enforces it. +pub const REDIRECT_PROXIES: &[&str] = &[ + "archivebate", + "clapdat", + "doodstream", + "lulustream", + "sxyprn", + "javtiful", + "spankbang", + "porndish", + "hqporner", + "heavyfetish", + "vjav", + "pornhd3x", + "vidara", + "pimpbunny", + "thaiporntv", + "tube8", + "supjav", +]; + +/// True for a `/proxy//...` URL (absolute or host-relative). +pub fn is_redirect_proxy_url(url: &str) -> bool { + let Some(rest) = url.split_once("/proxy/").map(|(_, rest)| rest) else { + return false; + }; + let name = rest.split('/').next().unwrap_or_default(); + REDIRECT_PROXIES.contains(&name) +} + pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("/archivebate/{endpoint}*") @@ -239,3 +273,39 @@ fn get_proxy(proxy: &str) -> Option { _ => None, } } + +#[cfg(test)] +mod redirect_proxy_tests { + use super::*; + + #[test] + fn recognises_redirect_proxy_urls() { + assert!(is_redirect_proxy_url("http://h/proxy/archivebate/archivebate.com/watch/1")); + assert!(is_redirect_proxy_url("/proxy/hqporner/x")); + assert!(!is_redirect_proxy_url("http://h/proxy/noodlemagazine/x")); + assert!(!is_redirect_proxy_url("https://cdn.example/video.mp4")); + } + + #[test] + fn redirect_proxy_list_matches_routes() { + let source = include_str!("proxy.rs"); + let config_body = source + .split("pub fn config(") + .nth(1) + .and_then(|rest| rest.split("async fn proxy2redirect(").next()) + .expect("config body"); + let mut from_routes: Vec = Vec::new(); + for block in config_body.split("web::resource(\"/").skip(1) { + let name = block.split("/{endpoint}").next().unwrap_or_default(); + let until_next = block.split("web::resource(").next().unwrap_or(block); + if until_next.contains("proxy2redirect") { + from_routes.push(name.to_string()); + } + } + let mut listed: Vec = REDIRECT_PROXIES.iter().map(|s| s.to_string()).collect(); + from_routes.sort(); + from_routes.dedup(); + listed.sort(); + assert_eq!(listed, from_routes); + } +}