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:
@@ -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/<id>-thumb/...`. The same applies to manifests with relative URIs.
|
||||
- Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route.
|
||||
|
||||
82
src/api.rs
82
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<UploadersRequest>,
|
||||
cache: web::types::State<VideoCache>,
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
70
src/proxy.rs
70
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/<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) {
|
||||
cfg.service(
|
||||
web::resource("/archivebate/{endpoint}*")
|
||||
@@ -239,3 +273,39 @@ fn get_proxy(proxy: &str) -> Option<AnyProxy> {
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user