rule34video: drop per-video enrichment; add on-demand page resolver proxy (off)
Listings no longer fetch every video page for formats/tags/artist, which
ran into the site's 429 limit. /proxy/rule34video/{id}/{slug} resolves the
page at play time and 302s to the signed mp4; the resolver lives in
util/rule34video_resolve.rs. Handing out proxy URLs is gated behind
USE_MEDIA_PROXY, currently false.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEp724zDpCWNaSeCrMSvjx
This commit is contained in:
117
src/util/rule34video_resolve.rs
Normal file
117
src/util/rule34video_resolve.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! rule34video.com video-page resolution for the `/proxy/rule34video/` redirect.
|
||||
//!
|
||||
//! Lives here (always compiled) because `HOT_TUB_PROVIDER=<id>` single-provider
|
||||
//! builds only compile the selected provider, while the proxy routes are
|
||||
//! always registered.
|
||||
//!
|
||||
//! yt-dlp's Rule34Video extractor no longer resolves the page URL ("No video
|
||||
//! formats found"), so the signed mp4 URLs (360p/480p/720p, valid ~12h, no
|
||||
//! cookie/Referer needed) come from the page's `var flashvars`.
|
||||
|
||||
use crate::util::requester::Requester;
|
||||
|
||||
const BASE_URL: &str = "https://rule34video.com";
|
||||
|
||||
/// Reads a single-quoted JS string value (`key: '...'`) from the
|
||||
/// `flashvars` object, honouring `\'` escapes.
|
||||
fn flashvar(block: &str, key: &str) -> Option<String> {
|
||||
let marker = format!("{}: '", key);
|
||||
let start = block.find(&marker)? + marker.len();
|
||||
let mut out = String::new();
|
||||
let mut chars = block[start..].chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\\' => out.push(chars.next()?),
|
||||
'\'' => return Some(out),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `(height, url)` for every quality on the page, best first.
|
||||
pub fn parse_formats(html: &str) -> Vec<(u32, String)> {
|
||||
let Some(start) = html.find("var flashvars") else {
|
||||
return vec![];
|
||||
};
|
||||
let block = &html[start..];
|
||||
let block = &block[..block.len().min(6000)];
|
||||
|
||||
let mut formats: Vec<(u32, String)> = Vec::new();
|
||||
for (url_key, text_key) in [
|
||||
("video_url", "video_url_text"),
|
||||
("video_alt_url", "video_alt_url_text"),
|
||||
("video_alt_url2", "video_alt_url2_text"),
|
||||
("video_alt_url3", "video_alt_url3_text"),
|
||||
] {
|
||||
let Some(url) = flashvar(block, url_key).filter(|url| url.starts_with("http")) else {
|
||||
continue;
|
||||
};
|
||||
let height = flashvar(block, text_key)
|
||||
.and_then(|quality| quality.trim_end_matches('p').parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
formats.push((height, url));
|
||||
}
|
||||
formats.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
formats
|
||||
}
|
||||
|
||||
/// `{id}/{slug}[/]` -> the video page URL. Only digits, letters, `-` and `_`
|
||||
/// are accepted so the endpoint cannot steer the request elsewhere.
|
||||
pub fn page_url(endpoint: &str) -> Option<String> {
|
||||
let mut parts = endpoint.trim_matches('/').split('/');
|
||||
let id = parts.next().filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))?;
|
||||
let slug = parts.next().filter(|slug| {
|
||||
!slug.is_empty() && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
})?;
|
||||
match parts.next() {
|
||||
None => Some(format!("{BASE_URL}/video/{id}/{slug}/")),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The best signed mp4 URL (or the one of `height`) for a video page.
|
||||
pub async fn resolve_media_url(
|
||||
requester: &mut Requester,
|
||||
page_url: &str,
|
||||
height: Option<u32>,
|
||||
) -> Option<String> {
|
||||
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
|
||||
let html = requester.get_probe(page_url, headers).await.ok().flatten()?;
|
||||
let formats = parse_formats(&html);
|
||||
height
|
||||
.and_then(|h| formats.iter().find(|(height, _)| *height == h))
|
||||
.or_else(|| formats.first())
|
||||
.map(|(_, url)| url.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_flashvars_formats_best_first() {
|
||||
let html = r#"var flashvars = { video_id: '1', video_url: 'https://rule34video.com/get_file/a_360.mp4/?v-acctoken=1', video_url_text: '360p', video_alt_url: 'https://rule34video.com/get_file/a_480p.mp4/?v-acctoken=2', video_alt_url_text: '480p', video_alt_url2: 'https://rule34video.com/get_file/a_720p.mp4/?v-acctoken=3', video_alt_url2_text: '720p', video_title: 'It\'s' };"#;
|
||||
let formats = parse_formats(html);
|
||||
assert_eq!(formats.len(), 3);
|
||||
assert_eq!(formats[0].0, 720);
|
||||
assert!(formats[0].1.contains("a_720p"));
|
||||
assert!(formats[2].1.contains("a_360"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_without_flashvars_yields_nothing() {
|
||||
assert!(parse_formats("<html></html>").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_page_url_from_safe_endpoints_only() {
|
||||
assert_eq!(
|
||||
page_url("123/some-slug/").as_deref(),
|
||||
Some("https://rule34video.com/video/123/some-slug/")
|
||||
);
|
||||
assert_eq!(page_url("123/../x"), None);
|
||||
assert_eq!(page_url("abc/slug"), None);
|
||||
assert_eq!(page_url("123/slug/extra"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user