upgrades
This commit is contained in:
@@ -362,13 +362,18 @@ impl JavtifulProvider {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
let duration = parse_time_to_seconds(&raw_duration).unwrap_or(0) as u32;
|
||||||
let (tags, formats, views) = self
|
let (tags, mut formats, views) = self
|
||||||
.extract_media(&video_url, &mut requester, options)
|
.extract_media(&video_url, &mut requester, options)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if preview.len() == 0 {
|
if preview.len() == 0 {
|
||||||
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
|
preview = format!("https://trailers.jav.si/preview/{id}.mp4");
|
||||||
}
|
}
|
||||||
|
if formats.is_empty() && !preview.is_empty() {
|
||||||
|
let mut format = VideoFormat::new(preview.clone(), "preview".to_string(), "video/mp4".to_string());
|
||||||
|
format.add_http_header("Referer".to_string(), video_url.clone());
|
||||||
|
formats.push(format);
|
||||||
|
}
|
||||||
let video_item = VideoItem::new(id, title, video_url, "javtiful".into(), thumb, duration)
|
let video_item = VideoItem::new(id, title, video_url, "javtiful".into(), thumb, duration)
|
||||||
.formats(formats)
|
.formats(formats)
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
@@ -428,23 +433,55 @@ impl JavtifulProvider {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let quality = "1080p".to_string();
|
let quality = "1080p".to_string();
|
||||||
let stripped_url = crate::providers::strip_url_scheme(url);
|
let mut formats = Vec::new();
|
||||||
let proxy_target = stripped_url
|
let video_id = url
|
||||||
.strip_prefix("www.javtiful.com/")
|
.split("/video/")
|
||||||
.or_else(|| stripped_url.strip_prefix("javtiful.com/"))
|
.nth(1)
|
||||||
.unwrap_or(stripped_url.as_str())
|
.and_then(|value| value.split('/').next())
|
||||||
.trim_start_matches('/')
|
.unwrap_or("")
|
||||||
.to_string();
|
.trim();
|
||||||
let video_url = crate::providers::build_proxy_url(
|
let token = text
|
||||||
options,
|
.split("data-csrf-token=\"")
|
||||||
"javtiful",
|
.nth(1)
|
||||||
&proxy_target,
|
.and_then(|value| value.split('"').next())
|
||||||
);
|
.unwrap_or("")
|
||||||
Ok((
|
.trim();
|
||||||
tags,
|
|
||||||
vec![VideoFormat::new(video_url, quality, "video/mp4".into())],
|
if !video_id.is_empty() && !token.is_empty() {
|
||||||
views,
|
let form = wreq::multipart::Form::new()
|
||||||
))
|
.text("video_id", video_id.to_string())
|
||||||
|
.text("pid_c", "".to_string())
|
||||||
|
.text("token", token.to_string());
|
||||||
|
|
||||||
|
if let Ok(response) = requester
|
||||||
|
.post_multipart(
|
||||||
|
"https://javtiful.com/ajax/get_cdn",
|
||||||
|
form,
|
||||||
|
vec![("Referer".to_string(), url.to_string())],
|
||||||
|
Some(Version::HTTP_11),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let payload = response.text().await.unwrap_or_default();
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {
|
||||||
|
if let Some(cdn_url) = json.get("playlists").and_then(|value| value.as_str()) {
|
||||||
|
if !cdn_url.trim().is_empty() {
|
||||||
|
let mut format = VideoFormat::new(
|
||||||
|
cdn_url.to_string(),
|
||||||
|
quality.clone(),
|
||||||
|
"m3u8".into(),
|
||||||
|
);
|
||||||
|
format.add_http_header("Referer".to_string(), url.to_string());
|
||||||
|
formats.push(format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = options;
|
||||||
|
|
||||||
|
Ok((tags, formats, views))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ pub static ALL_PROVIDERS: Lazy<HashMap<&'static str, DynProvider>> = Lazy::new(|
|
|||||||
|
|
||||||
const CHANNEL_STATUS_ERROR: &str = "error";
|
const CHANNEL_STATUS_ERROR: &str = "error";
|
||||||
const VALIDATION_RESULTS_REQUIRED: usize = 5;
|
const VALIDATION_RESULTS_REQUIRED: usize = 5;
|
||||||
const VALIDATION_MIN_SUCCESS: usize = 3;
|
const VALIDATION_MIN_SUCCESS: usize = 1;
|
||||||
const VALIDATION_COOLDOWN: Duration = Duration::from_secs(3600);
|
const VALIDATION_COOLDOWN: Duration = Duration::from_secs(3600);
|
||||||
const VALIDATION_MEDIA_TIMEOUT: Duration = Duration::from_secs(100);
|
const VALIDATION_MEDIA_TIMEOUT: Duration = Duration::from_secs(100);
|
||||||
const VALIDATION_ERROR_RETEST_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
const VALIDATION_ERROR_RETEST_INTERVAL: Duration = VALIDATION_COOLDOWN;
|
||||||
|
const VALIDATION_FAILURES_FOR_ERROR: u8 = 5;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ProviderValidationContext {
|
struct ProviderValidationContext {
|
||||||
@@ -48,10 +49,18 @@ struct ProviderValidationContext {
|
|||||||
requester: Requester,
|
requester: Requester,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ValidationFailureState {
|
||||||
|
consecutive_failures: u8,
|
||||||
|
last_counted_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
static PROVIDER_VALIDATION_CONTEXT: OnceLock<ProviderValidationContext> = OnceLock::new();
|
static PROVIDER_VALIDATION_CONTEXT: OnceLock<ProviderValidationContext> = OnceLock::new();
|
||||||
static PROVIDER_RUNTIME_STATUS: Lazy<DashMap<String, String>> = Lazy::new(DashMap::new);
|
static PROVIDER_RUNTIME_STATUS: Lazy<DashMap<String, String>> = Lazy::new(DashMap::new);
|
||||||
static PROVIDER_VALIDATION_INFLIGHT: Lazy<DashSet<String>> = Lazy::new(DashSet::new);
|
static PROVIDER_VALIDATION_INFLIGHT: Lazy<DashSet<String>> = Lazy::new(DashSet::new);
|
||||||
static PROVIDER_VALIDATION_LAST_RUN: Lazy<DashMap<String, Instant>> = Lazy::new(DashMap::new);
|
static PROVIDER_VALIDATION_LAST_RUN: Lazy<DashMap<String, Instant>> = Lazy::new(DashMap::new);
|
||||||
|
static PROVIDER_VALIDATION_FAILURE_STATE: Lazy<DashMap<String, ValidationFailureState>> =
|
||||||
|
Lazy::new(DashMap::new);
|
||||||
static PROVIDER_ERROR_REVALIDATION_STARTED: OnceLock<()> = OnceLock::new();
|
static PROVIDER_ERROR_REVALIDATION_STARTED: OnceLock<()> = OnceLock::new();
|
||||||
|
|
||||||
fn validation_client_version() -> ClientVersion {
|
fn validation_client_version() -> ClientVersion {
|
||||||
@@ -107,12 +116,27 @@ fn validation_request_for_channel(channel: &Channel) -> VideosRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn media_target(item: &VideoItem) -> (String, Vec<(String, String)>) {
|
fn media_targets(item: &VideoItem) -> Vec<(String, Vec<(String, String)>)> {
|
||||||
if let Some(format) = item.formats.as_ref().and_then(|formats| formats.first()) {
|
let mut targets = Vec::new();
|
||||||
return (format.url.clone(), format.http_headers_pairs());
|
|
||||||
|
if let Some(formats) = item.formats.as_ref() {
|
||||||
|
for format in formats {
|
||||||
|
if format.url.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targets.push((format.url.clone(), format.http_headers_pairs()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(item.url.clone(), Vec::new())
|
if !item.url.trim().is_empty()
|
||||||
|
&& !targets
|
||||||
|
.iter()
|
||||||
|
.any(|(url, _)| url.eq_ignore_ascii_case(item.url.as_str()))
|
||||||
|
{
|
||||||
|
targets.push((item.url.clone(), Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
targets
|
||||||
}
|
}
|
||||||
|
|
||||||
fn looks_like_media(content_type: &str, body: &[u8]) -> bool {
|
fn looks_like_media(content_type: &str, body: &[u8]) -> bool {
|
||||||
@@ -131,6 +155,23 @@ fn looks_like_media(content_type: &str, body: &[u8]) -> bool {
|
|||||||
|| body.windows(4).any(|window| window == b"mdat")
|
|| body.windows(4).any(|window| window == b"mdat")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_transient_validation_error(error: &str) -> bool {
|
||||||
|
let value = error.to_ascii_lowercase();
|
||||||
|
value.contains("client error (connect)")
|
||||||
|
|| value.contains("timed out")
|
||||||
|
|| value.contains("timeout")
|
||||||
|
|| value.contains("dns")
|
||||||
|
|| value.contains("connection reset")
|
||||||
|
|| value.contains("connection refused")
|
||||||
|
|| value.contains("temporarily unavailable")
|
||||||
|
|| value.contains("request returned 403")
|
||||||
|
|| value.contains("request returned 429")
|
||||||
|
|| value.contains("request returned 500")
|
||||||
|
|| value.contains("request returned 502")
|
||||||
|
|| value.contains("request returned 503")
|
||||||
|
|| value.contains("request returned 504")
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate_media_response(
|
async fn validate_media_response(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
item_index: usize,
|
item_index: usize,
|
||||||
@@ -256,42 +297,100 @@ async fn run_provider_validation(provider_id: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut successes = 0usize;
|
let mut successes = 0usize;
|
||||||
let mut failures = Vec::new();
|
let mut hard_failures = Vec::new();
|
||||||
|
let mut transient_failures = Vec::new();
|
||||||
for (item_index, item) in items.iter().take(VALIDATION_RESULTS_REQUIRED).enumerate() {
|
for (item_index, item) in items.iter().take(VALIDATION_RESULTS_REQUIRED).enumerate() {
|
||||||
let (url, headers) = media_target(item);
|
let targets = media_targets(item);
|
||||||
if url.is_empty() {
|
if targets.is_empty() {
|
||||||
failures.push(format!(
|
hard_failures.push(format!(
|
||||||
"{provider_id} item {} returned an empty media url",
|
"{provider_id} item {} returned an empty media url",
|
||||||
item_index + 1
|
item_index + 1
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match validate_media_response(
|
let mut item_errors = Vec::new();
|
||||||
provider_id,
|
let mut item_validated = false;
|
||||||
item_index,
|
for (url, headers) in targets {
|
||||||
&url,
|
if url.starts_with('/') {
|
||||||
headers,
|
continue;
|
||||||
context.requester.clone(),
|
}
|
||||||
)
|
item_validated = true;
|
||||||
.await
|
match validate_media_response(
|
||||||
{
|
provider_id,
|
||||||
Ok(()) => {
|
item_index,
|
||||||
successes += 1;
|
&url,
|
||||||
if successes >= VALIDATION_MIN_SUCCESS {
|
headers,
|
||||||
return Ok(());
|
context.requester.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
successes += 1;
|
||||||
|
if successes >= VALIDATION_MIN_SUCCESS {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
item_errors.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => item_errors.push(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item_validated && !item_errors.is_empty() {
|
||||||
|
for error in item_errors {
|
||||||
|
if is_transient_validation_error(&error) {
|
||||||
|
transient_failures.push(error);
|
||||||
|
} else {
|
||||||
|
hard_failures.push(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => failures.push(error),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if successes >= VALIDATION_MIN_SUCCESS {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if hard_failures.is_empty() && !transient_failures.is_empty() {
|
||||||
|
crate::flow_debug!(
|
||||||
|
"provider validation inconclusive provider={} transient_failures={}",
|
||||||
|
provider_id,
|
||||||
|
transient_failures.len()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"{provider_id} validation failed: only {successes} media checks passed (required at least {VALIDATION_MIN_SUCCESS}); failures={}",
|
"{provider_id} validation failed: only {successes} media checks passed (required at least {VALIDATION_MIN_SUCCESS}); hard_failures={}; transient_failures={}",
|
||||||
failures.join(" | ")
|
hard_failures.join(" | "),
|
||||||
|
transient_failures.join(" | ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reset_validation_failure_state(provider_id: &str) {
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_validation_failure(provider_id: &str, now: Instant) -> u8 {
|
||||||
|
if let Some(mut state) = PROVIDER_VALIDATION_FAILURE_STATE.get_mut(provider_id) {
|
||||||
|
if now.duration_since(state.last_counted_at) >= VALIDATION_COOLDOWN {
|
||||||
|
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
|
||||||
|
state.last_counted_at = now;
|
||||||
|
}
|
||||||
|
return state.consecutive_failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.insert(
|
||||||
|
provider_id.to_string(),
|
||||||
|
ValidationFailureState {
|
||||||
|
consecutive_failures: 1,
|
||||||
|
last_counted_at: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
fn start_periodic_error_revalidation() {
|
fn start_periodic_error_revalidation() {
|
||||||
if PROVIDER_ERROR_REVALIDATION_STARTED.set(()).is_err() {
|
if PROVIDER_ERROR_REVALIDATION_STARTED.set(()).is_err() {
|
||||||
return;
|
return;
|
||||||
@@ -383,14 +482,20 @@ pub fn schedule_provider_validation(provider_id: &str, context: &str, msg: &str)
|
|||||||
let validation_result = run_provider_validation(&provider_id).await;
|
let validation_result = run_provider_validation(&provider_id).await;
|
||||||
match validation_result {
|
match validation_result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
reset_validation_failure_state(&provider_id);
|
||||||
PROVIDER_RUNTIME_STATUS.remove(&provider_id);
|
PROVIDER_RUNTIME_STATUS.remove(&provider_id);
|
||||||
}
|
}
|
||||||
Err(_validation_error) => {
|
Err(_validation_error) => {
|
||||||
PROVIDER_RUNTIME_STATUS
|
let failures = record_validation_failure(&provider_id, Instant::now());
|
||||||
.insert(provider_id.clone(), CHANNEL_STATUS_ERROR.to_string());
|
if failures >= VALIDATION_FAILURES_FOR_ERROR {
|
||||||
|
PROVIDER_RUNTIME_STATUS
|
||||||
|
.insert(provider_id.clone(), CHANNEL_STATUS_ERROR.to_string());
|
||||||
|
}
|
||||||
crate::flow_debug!(
|
crate::flow_debug!(
|
||||||
"provider validation failed provider={} error={}",
|
"provider validation failed provider={} failures={} threshold={} error={}",
|
||||||
&provider_id,
|
&provider_id,
|
||||||
|
failures,
|
||||||
|
VALIDATION_FAILURES_FOR_ERROR,
|
||||||
crate::util::flow_debug::preview(&_validation_error, 160)
|
crate::util::flow_debug::preview(&_validation_error, 160)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -790,6 +895,8 @@ mod tests {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ApiVideoItem {
|
struct ApiVideoItem {
|
||||||
|
#[serde(default)]
|
||||||
|
title: String,
|
||||||
url: String,
|
url: String,
|
||||||
formats: Option<Vec<ApiVideoFormat>>,
|
formats: Option<Vec<ApiVideoFormat>>,
|
||||||
}
|
}
|
||||||
@@ -880,6 +987,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_for_channel_with_query(channel: &Channel, query: String) -> VideosRequest {
|
||||||
|
let mut request = request_for_channel(channel);
|
||||||
|
request.query = Some(query);
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_queries_for_channel(provider_id: &str, items: &[ApiVideoItem]) -> Vec<String> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
match provider_id {
|
||||||
|
"yesporn" => candidates.push("anal".to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
for token in item.title.split_whitespace() {
|
||||||
|
let cleaned = token
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_alphanumeric())
|
||||||
|
.collect::<String>();
|
||||||
|
if cleaned.len() >= 3
|
||||||
|
&& !candidates
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.eq_ignore_ascii_case(&cleaned))
|
||||||
|
{
|
||||||
|
candidates.push(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
candidates.push("video".to_string());
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
fn skip_reason_for_provider(provider_id: &str) -> Option<&'static str> {
|
fn skip_reason_for_provider(provider_id: &str) -> Option<&'static str> {
|
||||||
if std::env::var("FLARE_URL").is_ok() {
|
if std::env::var("FLARE_URL").is_ok() {
|
||||||
return None;
|
return None;
|
||||||
@@ -893,6 +1035,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_filter_matches(provider_id: &str) -> bool {
|
||||||
|
match std::env::var("HOTTUB_TEST_PROVIDER") {
|
||||||
|
Ok(filter) => filter.trim().is_empty() || filter.trim() == provider_id,
|
||||||
|
Err(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn media_target(item: &ApiVideoItem) -> (String, Vec<(String, String)>) {
|
fn media_target(item: &ApiVideoItem) -> (String, Vec<(String, String)>) {
|
||||||
if let Some(format) = item.formats.as_ref().and_then(|formats| formats.first()) {
|
if let Some(format) = item.formats.as_ref().and_then(|formats| formats.first()) {
|
||||||
let headers = format
|
let headers = format
|
||||||
@@ -940,7 +1089,12 @@ mod tests {
|
|||||||
let response = requester
|
let response = requester
|
||||||
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
|
.get_raw_with_headers_timeout(url, headers, Some(VALIDATION_MEDIA_TIMEOUT))
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("{provider_id} item {} request failed for {url}: {err}", item_index + 1))?;
|
.map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"{provider_id} item {} request failed for {url}: {err}",
|
||||||
|
item_index + 1
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -956,10 +1110,12 @@ mod tests {
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let body = response
|
let body = response.bytes().await.map_err(|err| {
|
||||||
.bytes()
|
format!(
|
||||||
.await
|
"{provider_id} item {} body read failed for {url}: {err}",
|
||||||
.map_err(|err| format!("{provider_id} item {} body read failed for {url}: {err}", item_index + 1))?;
|
item_index + 1
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -997,6 +1153,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_failure_streak_requires_hourly_spacing() {
|
||||||
|
let provider_id = "hsex";
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(record_validation_failure(provider_id, now), 1);
|
||||||
|
assert_eq!(record_validation_failure(provider_id, now), 1);
|
||||||
|
assert_eq!(
|
||||||
|
record_validation_failure(provider_id, now + VALIDATION_COOLDOWN),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
record_validation_failure(provider_id, now + VALIDATION_COOLDOWN * 2),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_failure_streak_resets_after_success() {
|
||||||
|
let provider_id = "hsex";
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(record_validation_failure(provider_id, now), 1);
|
||||||
|
assert_eq!(
|
||||||
|
record_validation_failure(provider_id, now + VALIDATION_COOLDOWN),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
reset_validation_failure_state(provider_id);
|
||||||
|
assert_eq!(
|
||||||
|
record_validation_failure(provider_id, now + VALIDATION_COOLDOWN * 2),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_failure_threshold_matches_channel_error_policy() {
|
||||||
|
let provider_id = "hsex";
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut counted = 0;
|
||||||
|
for step in 0..VALIDATION_FAILURES_FOR_ERROR {
|
||||||
|
counted = record_validation_failure(provider_id, now + VALIDATION_COOLDOWN * step as u32);
|
||||||
|
}
|
||||||
|
assert_eq!(counted, VALIDATION_FAILURES_FOR_ERROR);
|
||||||
|
|
||||||
|
PROVIDER_VALIDATION_FAILURE_STATE.remove(provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_group_index() {
|
fn builds_group_index() {
|
||||||
PROVIDER_RUNTIME_STATUS.remove("all");
|
PROVIDER_RUNTIME_STATUS.remove("all");
|
||||||
@@ -1114,6 +1325,7 @@ mod tests {
|
|||||||
let mut channels = ALL_PROVIDERS
|
let mut channels = ALL_PROVIDERS
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(provider_id, _)| **provider_id != "all")
|
.filter(|(provider_id, _)| **provider_id != "all")
|
||||||
|
.filter(|(provider_id, _)| provider_filter_matches(provider_id))
|
||||||
.filter_map(|(_, provider)| provider.get_channel(client_version.clone()))
|
.filter_map(|(_, provider)| provider.get_channel(client_version.clone()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
channels.sort_by(|a, b| a.id.cmp(&b.id));
|
channels.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
@@ -1199,4 +1411,163 @@ mod tests {
|
|||||||
eprintln!("skipped providers:\n{}", skipped.join("\n"));
|
eprintln!("skipped providers:\n{}", skipped.join("\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ntex::test]
|
||||||
|
#[ignore = "live search sweep across all providers"]
|
||||||
|
async fn api_videos_search_returns_working_media_urls_for_all_channels() {
|
||||||
|
let app = test::init_service(
|
||||||
|
web::App::new()
|
||||||
|
.state(test_db_pool())
|
||||||
|
.state(VideoCache::new().max_size(10_000).to_owned())
|
||||||
|
.state(Requester::new())
|
||||||
|
.service(web::scope("/api").configure(crate::api::config)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client_version = ClientVersion::new(22, 'c' as u32, "Hot%20Tub".to_string());
|
||||||
|
let mut channels = ALL_PROVIDERS
|
||||||
|
.iter()
|
||||||
|
.filter(|(provider_id, _)| **provider_id != "all")
|
||||||
|
.filter(|(provider_id, _)| provider_filter_matches(provider_id))
|
||||||
|
.filter_map(|(_, provider)| provider.get_channel(client_version.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
channels.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
let mut skipped = Vec::new();
|
||||||
|
|
||||||
|
for channel in channels {
|
||||||
|
let provider_id = channel.id.clone();
|
||||||
|
|
||||||
|
if let Some(reason) = skip_reason_for_provider(&provider_id) {
|
||||||
|
skipped.push(format!("{provider_id}: {reason}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline_payload = request_for_channel(&channel);
|
||||||
|
let baseline_request = test::TestRequest::post()
|
||||||
|
.uri("/api/videos")
|
||||||
|
.header(
|
||||||
|
header::USER_AGENT,
|
||||||
|
"Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0",
|
||||||
|
)
|
||||||
|
.set_json(&baseline_payload)
|
||||||
|
.to_request();
|
||||||
|
|
||||||
|
let baseline_response = test::call_service(&app, baseline_request).await;
|
||||||
|
let baseline_status = baseline_response.status();
|
||||||
|
let baseline_body = test::read_body(baseline_response).await;
|
||||||
|
if !baseline_status.is_success() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{provider_id} baseline request returned status {baseline_status}: {}",
|
||||||
|
String::from_utf8_lossy(&baseline_body)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let baseline: ApiVideosResponse = match serde_json::from_slice(&baseline_body) {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(error) => {
|
||||||
|
failures.push(format!(
|
||||||
|
"{provider_id} baseline returned invalid JSON: {error}; body={}",
|
||||||
|
String::from_utf8_lossy(&baseline_body)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if baseline.items.is_empty() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{provider_id} baseline returned no items for search seed"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected_payload: Option<ApiVideosResponse> = None;
|
||||||
|
let mut last_error: Option<String> = None;
|
||||||
|
for search_query in search_queries_for_channel(&provider_id, &baseline.items)
|
||||||
|
.into_iter()
|
||||||
|
.take(12)
|
||||||
|
{
|
||||||
|
if search_query.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = request_for_channel_with_query(&channel, search_query.clone());
|
||||||
|
let request = test::TestRequest::post()
|
||||||
|
.uri("/api/videos")
|
||||||
|
.header(
|
||||||
|
header::USER_AGENT,
|
||||||
|
"Hot%20Tub/22c CFNetwork/1494.0.7 Darwin/23.4.0",
|
||||||
|
)
|
||||||
|
.set_json(&payload)
|
||||||
|
.to_request();
|
||||||
|
|
||||||
|
let response = test::call_service(&app, request).await;
|
||||||
|
let status = response.status();
|
||||||
|
let body = test::read_body(response).await;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
last_error = Some(format!(
|
||||||
|
"{provider_id} search query={search_query} returned status {status}: {}",
|
||||||
|
String::from_utf8_lossy(&body)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: ApiVideosResponse = match serde_json::from_slice(&body) {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(format!(
|
||||||
|
"{provider_id} search query={search_query} returned invalid JSON: {error}; body={}",
|
||||||
|
String::from_utf8_lossy(&body)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload.items.len() >= 5 {
|
||||||
|
selected_payload = Some(payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
last_error = Some(format!(
|
||||||
|
"{provider_id} search query={search_query} returned fewer than 5 items: {}",
|
||||||
|
payload.items.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(payload) = selected_payload else {
|
||||||
|
failures.push(last_error.unwrap_or_else(|| {
|
||||||
|
format!("{provider_id} search did not yield at least 5 items")
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (item_index, item) in payload.items.iter().take(5).enumerate() {
|
||||||
|
let (url, headers) = media_target(item);
|
||||||
|
if url.is_empty() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{provider_id} search item {} returned an empty media url",
|
||||||
|
item_index + 1
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
assert_media_response(&provider_id, item_index, &url, headers).await
|
||||||
|
{
|
||||||
|
failures.push(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"provider live search sweep failed:\n{}",
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
if !skipped.is_empty() {
|
||||||
|
eprintln!("skipped providers:\n{}", skipped.join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ impl SextbProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let line_regex = Self::regex(r#"(?m)^(?P<key>Director|Label|Studio|Cast\(s\)|Genre\(s\)|Quality|Release Date|Runtimes|Added|Viewed|Description):\s*(?P<value>.+)$"#)?;
|
let line_regex = Self::regex(r#"(?m)^\s*(?P<key>Director|Label|Studio|Cast\(s\)|Genre\(s\)|Quality|Release Date|Runtimes|Added|Viewed|Description):\s*(?P<value>.+)$"#)?;
|
||||||
for captures in line_regex.captures_iter(markdown) {
|
for captures in line_regex.captures_iter(markdown) {
|
||||||
let key = captures.name("key").map(|value| value.as_str()).unwrap_or_default();
|
let key = captures.name("key").map(|value| value.as_str()).unwrap_or_default();
|
||||||
let value = captures.name("value").map(|value| value.as_str()).unwrap_or_default().trim();
|
let value = captures.name("value").map(|value| value.as_str()).unwrap_or_default().trim();
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ impl SpankbangProvider {
|
|||||||
.select(video_link_selector)
|
.select(video_link_selector)
|
||||||
.find_map(|link| link.value().attr("href"))
|
.find_map(|link| link.value().attr("href"))
|
||||||
.map(ToString::to_string)?;
|
.map(ToString::to_string)?;
|
||||||
|
let detail_url = self.normalize_url(&href);
|
||||||
let thumb = card
|
let thumb = card
|
||||||
.select(thumb_selector)
|
.select(thumb_selector)
|
||||||
.find_map(|img| img.value().attr("src"))
|
.find_map(|img| img.value().attr("src"))
|
||||||
@@ -511,7 +512,10 @@ impl SpankbangProvider {
|
|||||||
item = item.rating(rating);
|
item = item.rating(rating);
|
||||||
}
|
}
|
||||||
if let Some(preview) = preview {
|
if let Some(preview) = preview {
|
||||||
item = item.preview(preview);
|
let mut format =
|
||||||
|
VideoFormat::new(preview.clone(), "preview".to_string(), "video/mp4".to_string());
|
||||||
|
format.add_http_header("Referer".to_string(), detail_url.clone());
|
||||||
|
item = item.preview(preview).formats(vec![format]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(meta_link) = card.select(meta_link_selector).next() {
|
if let Some(meta_link) = card.select(meta_link_selector).next() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::status::*;
|
|||||||
use crate::util::cache::VideoCache;
|
use crate::util::cache::VideoCache;
|
||||||
use crate::util::parse_abbreviated_number;
|
use crate::util::parse_abbreviated_number;
|
||||||
use crate::util::time::parse_time_to_seconds;
|
use crate::util::time::parse_time_to_seconds;
|
||||||
use crate::videos::{ServerOptions, VideoItem};
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use error_chain::error_chain;
|
use error_chain::error_chain;
|
||||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||||
@@ -384,6 +384,9 @@ impl ViralxxxpornProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let thumb = self.extract_thumb_url(segment);
|
let thumb = self.extract_thumb_url(segment);
|
||||||
|
let preview = Self::first_non_empty_attr(segment, &["data-preview=\""])
|
||||||
|
.map(|value| self.normalize_url(&value))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let text_segment = Self::normalize_ws(&Self::decode_html(&Self::strip_tags(segment)));
|
let text_segment = Self::normalize_ws(&Self::decode_html(&Self::strip_tags(segment)));
|
||||||
let duration = Self::extract_duration_seconds(segment)
|
let duration = Self::extract_duration_seconds(segment)
|
||||||
@@ -398,6 +401,15 @@ impl ViralxxxpornProvider {
|
|||||||
if views > 0 {
|
if views > 0 {
|
||||||
item = item.views(views);
|
item = item.views(views);
|
||||||
}
|
}
|
||||||
|
if !preview.is_empty() {
|
||||||
|
let mut format = VideoFormat::new(
|
||||||
|
preview.clone(),
|
||||||
|
"preview".to_string(),
|
||||||
|
"video/mp4".to_string(),
|
||||||
|
);
|
||||||
|
format.add_http_header("Referer".to_string(), item.url.clone());
|
||||||
|
item = item.preview(preview).formats(vec![format]);
|
||||||
|
}
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +472,9 @@ impl ViralxxxpornProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let thumb = self.extract_thumb_url(segment);
|
let thumb = self.extract_thumb_url(segment);
|
||||||
|
let preview = Self::first_non_empty_attr(segment, &["data-preview=\""])
|
||||||
|
.map(|value| self.normalize_url(&value))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let raw_duration = Self::extract_between(segment, "<div class=\"duration\">", "<")
|
let raw_duration = Self::extract_between(segment, "<div class=\"duration\">", "<")
|
||||||
.or_else(|| Self::extract_between(segment, "<div class=\"time\">", "<"))
|
.or_else(|| Self::extract_between(segment, "<div class=\"time\">", "<"))
|
||||||
@@ -490,6 +505,15 @@ impl ViralxxxpornProvider {
|
|||||||
if views > 0 {
|
if views > 0 {
|
||||||
item = item.views(views);
|
item = item.views(views);
|
||||||
}
|
}
|
||||||
|
if !preview.is_empty() {
|
||||||
|
let mut format = VideoFormat::new(
|
||||||
|
preview.clone(),
|
||||||
|
"preview".to_string(),
|
||||||
|
"video/mp4".to_string(),
|
||||||
|
);
|
||||||
|
format.add_http_header("Referer".to_string(), item.url.clone());
|
||||||
|
item = item.preview(preview).formats(vec![format]);
|
||||||
|
}
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::status::*;
|
|||||||
use crate::util::cache::VideoCache;
|
use crate::util::cache::VideoCache;
|
||||||
use crate::util::parse_abbreviated_number;
|
use crate::util::parse_abbreviated_number;
|
||||||
use crate::util::time::parse_time_to_seconds;
|
use crate::util::time::parse_time_to_seconds;
|
||||||
use crate::videos::{ServerOptions, VideoItem};
|
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use error_chain::error_chain;
|
use error_chain::error_chain;
|
||||||
use htmlentity::entity::{ICodedDataTrait, decode};
|
use htmlentity::entity::{ICodedDataTrait, decode};
|
||||||
@@ -299,8 +299,20 @@ impl XxthotsProvider {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
let views = parse_abbreviated_number(&views_part).unwrap_or(0) as u32;
|
||||||
|
let preview = video_segment
|
||||||
|
.split("data-preview=\"")
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.get(1)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split('"')
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let video_item = VideoItem::new(
|
let mut video_item = VideoItem::new(
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
video_url.to_string(),
|
video_url.to_string(),
|
||||||
@@ -309,6 +321,15 @@ impl XxthotsProvider {
|
|||||||
duration,
|
duration,
|
||||||
)
|
)
|
||||||
.views(views);
|
.views(views);
|
||||||
|
if !preview.is_empty() {
|
||||||
|
let mut format = VideoFormat::new(
|
||||||
|
preview.clone(),
|
||||||
|
"preview".to_string(),
|
||||||
|
"video/mp4".to_string(),
|
||||||
|
);
|
||||||
|
format.add_http_header("Referer".to_string(), video_url.clone());
|
||||||
|
video_item = video_item.preview(preview).formats(vec![format]);
|
||||||
|
}
|
||||||
items.push(video_item);
|
items.push(video_item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
Reference in New Issue
Block a user