shooshtime fix

This commit is contained in:
Simon
2026-03-23 13:46:55 +00:00
parent 90ce9c684b
commit 99fe4c947c
5 changed files with 417 additions and 11 deletions

View File

@@ -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<VideoItem> {
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"
);
}
}

View File

@@ -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();
};

View File

@@ -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;

301
src/proxies/shooshtime.rs Normal file
View File

@@ -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<String>)> {
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> {
Regex::new(value).ok()
}
fn extract_js_value(block: &str, regex: &Regex) -> Option<String> {
regex
.captures(block)
.and_then(|value| value.get(1))
.map(|value| value.as_str().replace("\\/", "/").replace("\\'", "'"))
}
fn extract_sources(html: &str) -> Vec<SourceCandidate> {
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::<String>()
.parse::<u32>()
.unwrap_or(0)
}
fn select_source_url(html: &str, quality: Option<&str>) -> Option<String> {
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<Requester>,
) -> Result<impl web::Responder, web::Error> {
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#"
<script>
var flashvars = {
video_url: 'https://shooshtime.com/get_file/1/token/1/2/3.mp4/?x=1',
video_url_text: '480p',
video_alt_url: 'https://shooshtime.com/get_file/1/token/1/2/3_720p.mp4/?x=2',
video_alt_url_text: '720p'
};
</script>
"#;
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")
);
}
}

View File

@@ -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))