From 99fe4c947c3b0e99e0ce7834702c1372df17d5fa Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 23 Mar 2026 13:46:55 +0000 Subject: [PATCH] shooshtime fix --- src/providers/shooshtime.rs | 116 ++++++++++++-- src/proxies/doodstream.rs | 5 +- src/proxies/mod.rs | 1 + src/proxies/shooshtime.rs | 301 ++++++++++++++++++++++++++++++++++++ src/proxy.rs | 5 + 5 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 src/proxies/shooshtime.rs diff --git a/src/providers/shooshtime.rs b/src/providers/shooshtime.rs index d26f03f..063f4dc 100644 --- a/src/providers/shooshtime.rs +++ b/src/providers/shooshtime.rs @@ -1,6 +1,9 @@ use crate::DbPool; use crate::api::ClientVersion; -use crate::providers::{Provider, report_provider_error, report_provider_error_background}; +use crate::providers::{ + Provider, build_proxy_url, report_provider_error, report_provider_error_background, + strip_url_scheme, +}; use crate::status::*; use crate::util::cache::VideoCache; use crate::util::parse_abbreviated_number; @@ -16,6 +19,7 @@ use regex::Regex; use scraper::{ElementRef, Html, Selector}; use std::sync::{Arc, RwLock}; use std::{thread, vec}; +use url::Url; pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = crate::providers::ProviderChannelMetadata { @@ -608,6 +612,41 @@ impl ShooshtimeProvider { } } + fn is_allowed_detail_url(&self, value: &str) -> bool { + let normalized = self.normalize_url(value); + let Some(url) = Url::parse(&normalized).ok() else { + return false; + }; + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + (host == "shooshtime.com" || host == "www.shooshtime.com") + && url.path().starts_with("/videos/") + } + + fn proxied_video( + &self, + options: &ServerOptions, + detail_url: &str, + quality: Option<&str>, + ) -> String { + if detail_url.is_empty() || !self.is_allowed_detail_url(detail_url) { + return detail_url.to_string(); + } + + let mut target = strip_url_scheme(detail_url); + if let Some(quality) = quality.map(str::trim).filter(|quality| !quality.is_empty()) { + target = target.trim_end_matches('/').to_string(); + target.push_str("/__quality__/"); + target.push_str(&quality.replace(' ', "%20")); + } + + build_proxy_url(options, "shooshtime", &target) + } + fn search_sort_param(sort: &str) -> Option<&'static str> { match Self::normalize_sort(sort) { "viewed" => Some("video_viewed"), @@ -906,6 +945,7 @@ impl ShooshtimeProvider { mut item: VideoItem, html: &str, page_url: &str, + options: &ServerOptions, ) -> Result { let flashvars_regex = Self::regex(r#"(?s)var\s+flashvars\s*=\s*\{(.*?)\};"#)?; let value_regex = |key: &str| Self::regex(&format!(r#"{key}:\s*'([^']*)'"#)); @@ -935,17 +975,17 @@ impl ShooshtimeProvider { let mut formats = Vec::new(); if let Some(url) = &primary_url { + let format_url = self.proxied_video(options, page_url, Some(&primary_quality)); formats.push( - VideoFormat::new(url.clone(), primary_quality.clone(), "mp4".to_string()) - .format_id(primary_quality.clone()) - .http_header("Referer".to_string(), page_url.to_string()), + VideoFormat::new(format_url, primary_quality.clone(), "mp4".to_string()) + .format_id(primary_quality.clone()), ); } if let Some(url) = &alt_url { + let format_url = self.proxied_video(options, page_url, Some(&alt_quality)); formats.push( - VideoFormat::new(url.clone(), alt_quality.clone(), "mp4".to_string()) - .format_id(alt_quality.clone()) - .http_header("Referer".to_string(), page_url.to_string()), + VideoFormat::new(format_url, alt_quality.clone(), "mp4".to_string()) + .format_id(alt_quality.clone()), ); } @@ -1115,6 +1155,10 @@ impl ShooshtimeProvider { if let Some(title) = title { item.title = title; } + let proxied_url = self.proxied_video(options, page_url, None); + if !proxied_url.is_empty() { + item.url = proxied_url; + } if !formats.is_empty() { item = item.formats(formats); } @@ -1134,7 +1178,7 @@ impl ShooshtimeProvider { item = item.uploader_url(uploader_url); } if !tags.is_empty() { - item = item.tags(tags); + item.tags = Some(tags); } if item.preview.is_none() { if let Some(preview) = preview_url.as_ref() { @@ -1171,7 +1215,7 @@ impl ShooshtimeProvider { } }; - match self.apply_detail_video(item, &html, &page_url) { + match self.apply_detail_video(item, &html, &page_url, options) { Ok(item) => item, Err(error) => { report_provider_error_background( @@ -1328,7 +1372,26 @@ mod tests { "#; let enriched = provider - .apply_detail_video(item, html, "https://shooshtime.com/videos/example/123/") + .apply_detail_video( + item, + html, + "https://shooshtime.com/videos/example/123/", + &crate::videos::ServerOptions { + featured: None, + category: None, + sites: None, + filter: None, + language: None, + public_url_base: None, + requester: None, + network: None, + stars: None, + categories: None, + duration: None, + sort: None, + sexuality: None, + }, + ) .unwrap(); assert_eq!(enriched.thumb, "https://shooshtime.com/list-thumb.jpg"); @@ -1337,4 +1400,37 @@ mod tests { Some("https://shooshtime.com/detail-thumb.jpg") ); } + + #[test] + fn builds_proxied_video_urls() { + let provider = ShooshtimeProvider::new(); + let options = crate::videos::ServerOptions { + featured: None, + category: None, + sites: None, + filter: None, + language: None, + public_url_base: Some("https://example.com".to_string()), + requester: None, + network: None, + stars: None, + categories: None, + duration: None, + sort: None, + sexuality: None, + }; + + assert_eq!( + provider.proxied_video(&options, "https://shooshtime.com/videos/example/123/", None,), + "https://example.com/proxy/shooshtime/shooshtime.com/videos/example/123/" + ); + assert_eq!( + provider.proxied_video( + &options, + "https://shooshtime.com/videos/example/123/", + Some("720p"), + ), + "https://example.com/proxy/shooshtime/shooshtime.com/videos/example/123/__quality__/720p" + ); + } } diff --git a/src/proxies/doodstream.rs b/src/proxies/doodstream.rs index 08269f3..ef95d7f 100644 --- a/src/proxies/doodstream.rs +++ b/src/proxies/doodstream.rs @@ -165,7 +165,10 @@ impl DoodstreamProxy { let token_regex = Self::regex(r"\b[0-9a-z]+\b")?; payload = token_regex .replace_all(&payload, |captures: &Captures| { - let token = captures.get(0).map(|value| value.as_str()).unwrap_or_default(); + let token = captures + .get(0) + .map(|value| value.as_str()) + .unwrap_or_default(); let Some(index) = Self::decode_base36(token) else { return token.to_string(); }; diff --git a/src/proxies/mod.rs b/src/proxies/mod.rs index 009abeb..0cc673c 100644 --- a/src/proxies/mod.rs +++ b/src/proxies/mod.rs @@ -17,6 +17,7 @@ pub mod pimpbunnythumb; pub mod porndish; pub mod porndishthumb; pub mod pornhd3x; +pub mod shooshtime; pub mod spankbang; pub mod sxyprn; diff --git a/src/proxies/shooshtime.rs b/src/proxies/shooshtime.rs new file mode 100644 index 0000000..6571847 --- /dev/null +++ b/src/proxies/shooshtime.rs @@ -0,0 +1,301 @@ +use ntex::http::Response; +use ntex::http::header::{CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE}; +use ntex::web::{self, HttpRequest, error}; +use regex::Regex; +use url::Url; + +use crate::util::requester::Requester; + +const BASE_URL: &str = "https://shooshtime.com"; + +#[derive(Debug, Clone)] +struct SourceCandidate { + url: String, + quality: String, +} + +#[derive(Debug, Clone)] +pub struct ShooshtimeProxy {} + +impl ShooshtimeProxy { + pub fn new() -> Self { + Self {} + } + + fn normalize_detail_request(endpoint: &str) -> Option<(String, Option)> { + let endpoint = endpoint.trim().trim_start_matches('/'); + if endpoint.is_empty() { + return None; + } + + let (detail_part, quality) = match endpoint.split_once("/__quality__/") { + Some((detail, quality)) => { + (detail, Some(quality.replace("%20", " ").trim().to_string())) + } + None => (endpoint, None), + }; + + let mut detail_url = + if detail_part.starts_with("http://") || detail_part.starts_with("https://") { + detail_part.to_string() + } else { + format!("https://{}", detail_part.trim_start_matches('/')) + }; + + if detail_url.contains("/videos/") && !detail_url.ends_with('/') { + detail_url.push('/'); + } + + Self::is_allowed_detail_url(&detail_url) + .then_some((detail_url, quality.filter(|value| !value.is_empty()))) + } + + fn is_allowed_detail_url(url: &str) -> bool { + let Some(url) = Url::parse(url).ok() else { + return false; + }; + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + (host == "shooshtime.com" || host == "www.shooshtime.com") + && url.path().starts_with("/videos/") + } + + fn is_allowed_media_url(url: &str) -> bool { + let Some(url) = Url::parse(url).ok() else { + return false; + }; + if url.scheme() != "https" { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + (host == "shooshtime.com" || host == "www.shooshtime.com") + && url.path().starts_with("/get_file/") + } + + fn normalize_url(raw: &str) -> String { + let value = raw.trim().replace("\\/", "/"); + if value.is_empty() { + return String::new(); + } + if value.starts_with("//") { + return format!("https:{value}"); + } + if value.starts_with('/') { + return format!("{BASE_URL}{value}"); + } + if value.starts_with("http://") { + return value.replacen("http://", "https://", 1); + } + value + } + + fn regex(value: &str) -> Option { + Regex::new(value).ok() + } + + fn extract_js_value(block: &str, regex: &Regex) -> Option { + regex + .captures(block) + .and_then(|value| value.get(1)) + .map(|value| value.as_str().replace("\\/", "/").replace("\\'", "'")) + } + + fn extract_sources(html: &str) -> Vec { + let Some(flashvars_regex) = Self::regex(r#"(?s)var\s+flashvars\s*=\s*\{(.*?)\};"#) else { + return vec![]; + }; + let Some(flashvars) = flashvars_regex + .captures(html) + .and_then(|value| value.get(1)) + .map(|value| value.as_str().to_string()) + else { + return vec![]; + }; + + let value_regex = |key: &str| Self::regex(&format!(r#"{key}:\s*'([^']*)'"#)); + let primary_url_regex = match value_regex("video_url") { + Some(value) => value, + None => return vec![], + }; + let primary_quality_regex = match value_regex("video_url_text") { + Some(value) => value, + None => return vec![], + }; + let alt_url_regex = match value_regex("video_alt_url") { + Some(value) => value, + None => return vec![], + }; + let alt_quality_regex = match value_regex("video_alt_url_text") { + Some(value) => value, + None => return vec![], + }; + + let mut sources = Vec::new(); + + if let Some(url) = Self::extract_js_value(&flashvars, &primary_url_regex) { + let normalized = Self::normalize_url(&url); + if !normalized.is_empty() && Self::is_allowed_media_url(&normalized) { + sources.push(SourceCandidate { + url: normalized, + quality: Self::extract_js_value(&flashvars, &primary_quality_regex) + .unwrap_or_else(|| "480p".to_string()), + }); + } + } + + if let Some(url) = Self::extract_js_value(&flashvars, &alt_url_regex) { + let normalized = Self::normalize_url(&url); + if !normalized.is_empty() && Self::is_allowed_media_url(&normalized) { + sources.push(SourceCandidate { + url: normalized, + quality: Self::extract_js_value(&flashvars, &alt_quality_regex) + .unwrap_or_else(|| "720p".to_string()), + }); + } + } + + sources + } + + fn quality_score(label: &str) -> u32 { + label + .chars() + .filter(|value| value.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) + } + + fn select_source_url(html: &str, quality: Option<&str>) -> Option { + let sources = Self::extract_sources(html); + if sources.is_empty() { + return None; + } + + if let Some(quality) = quality { + let wanted = quality.trim().to_ascii_lowercase(); + if let Some(source) = sources + .iter() + .find(|source| source.quality.trim().to_ascii_lowercase() == wanted) + { + return Some(source.url.clone()); + } + } + + sources + .iter() + .max_by_key(|source| Self::quality_score(&source.quality)) + .map(|source| source.url.clone()) + } +} + +pub async fn serve_media( + req: HttpRequest, + requester: web::types::State, +) -> Result { + let endpoint = req.match_info().query("endpoint").to_string(); + let Some((detail_url, quality)) = ShooshtimeProxy::normalize_detail_request(&endpoint) else { + return Ok(web::HttpResponse::BadRequest().finish()); + }; + + let mut requester = requester.get_ref().clone(); + let html = match requester.get(&detail_url, None).await { + Ok(html) => html, + Err(_) => return Ok(web::HttpResponse::BadGateway().finish()), + }; + + let Some(source_url) = ShooshtimeProxy::select_source_url(&html, quality.as_deref()) else { + return Ok(web::HttpResponse::BadGateway().finish()); + }; + + let mut headers = vec![("Referer".to_string(), detail_url)]; + if let Some(range) = req + .headers() + .get("Range") + .and_then(|value| value.to_str().ok()) + { + headers.push(("Range".to_string(), range.to_string())); + } + + let upstream = match requester.get_raw_with_headers(&source_url, headers).await { + Ok(response) => response, + Err(_) => return Ok(web::HttpResponse::BadGateway().finish()), + }; + + let status = upstream.status(); + let upstream_headers = upstream.headers().clone(); + let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?; + + let mut response = Response::build(status); + if let Some(value) = upstream_headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + { + response.set_header(CONTENT_TYPE, value); + } + if let Some(value) = upstream_headers + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + { + response.set_header(CONTENT_LENGTH, value); + } + if let Some(value) = upstream_headers + .get(CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + { + response.set_header(CONTENT_RANGE, value); + } + if let Some(value) = upstream_headers + .get("Accept-Ranges") + .and_then(|value| value.to_str().ok()) + { + response.set_header("Accept-Ranges", value); + } + + Ok(response.body(bytes.to_vec())) +} + +#[cfg(test)] +mod tests { + use super::ShooshtimeProxy; + + #[test] + fn normalizes_detail_endpoint_and_quality() { + let (url, quality) = ShooshtimeProxy::normalize_detail_request( + "shooshtime.com/videos/example/123/__quality__/720p", + ) + .expect("proxy target should parse"); + + assert_eq!(url, "https://shooshtime.com/videos/example/123/"); + assert_eq!(quality.as_deref(), Some("720p")); + } + + #[test] + fn selects_requested_or_best_quality() { + let html = r#" + + "#; + + assert_eq!( + ShooshtimeProxy::select_source_url(html, Some("480p")).as_deref(), + Some("https://shooshtime.com/get_file/1/token/1/2/3.mp4/?x=1") + ); + assert_eq!( + ShooshtimeProxy::select_source_url(html, None).as_deref(), + Some("https://shooshtime.com/get_file/1/token/1/2/3_720p.mp4/?x=2") + ); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index fe40cfc..8d8354b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -41,6 +41,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route(web::post().to(proxy2redirect)) .route(web::get().to(proxy2redirect)), ) + .service( + web::resource("/shooshtime/{endpoint}*") + .route(web::post().to(crate::proxies::shooshtime::serve_media)) + .route(web::get().to(crate::proxies::shooshtime::serve_media)), + ) .service( web::resource("/pimpbunny/{endpoint}*") .route(web::post().to(proxy2redirect))