Move redirecting-proxy formats to video.url for verified app clients; document the rule

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
Simon
2026-10-19 07:48:34 +00:00
parent f900987434
commit 6c69b46395
3 changed files with 153 additions and 0 deletions

View File

@@ -26,6 +26,7 @@ These rules cut across every step below. They are intentionally grouped at the t
### Output Shape ### 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. - 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/<id>-thumb/...`. The same applies to manifests with relative URIs. - If thumbnails need a `Referer` and the player has no per-thumb header mechanism, proxy them via `/proxy/<id>-thumb/...`. The same applies to manifests with relative URIs.
- Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route. - Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route.

View File

@@ -714,6 +714,15 @@ async fn videos_post(
.collect(); .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() { if let Some(literal_query) = literal_query.as_deref() {
#[cfg(feature = "debug")] #[cfg(feature = "debug")]
let before = video_items.len(); let before = video_items.len();
@@ -794,6 +803,30 @@ async fn videos_post(
Ok(web::HttpResponse::Ok().json(&videos)) 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( async fn uploaders_post(
uploader_request: web::types::Json<UploadersRequest>, uploader_request: web::types::Json<UploadersRequest>,
cache: web::types::State<VideoCache>, cache: web::types::State<VideoCache>,
@@ -1082,4 +1115,53 @@ mod tests {
assert_eq!(item.uploader.as_deref(), Some("FreeusePorn")); assert_eq!(item.uploader.as_deref(), Some("FreeusePorn"));
assert_eq!(item.formats.as_ref().map(|formats| formats.len()), Some(2)); 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));
}
} }

View File

@@ -20,6 +20,40 @@ use crate::proxies::thaiporntv::ThaipornTvProxy;
use crate::proxies::*; use crate::proxies::*;
use crate::util::requester::Requester; 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/<redirect 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::resource("/archivebate/{endpoint}*") web::resource("/archivebate/{endpoint}*")
@@ -239,3 +273,39 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
_ => None, _ => 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<String> = 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<String> = REDIRECT_PROXIES.iter().map(|s| s.to_string()).collect();
from_routes.sort();
from_routes.dedup();
listed.sort();
assert_eq!(listed, from_routes);
}
}