mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-20 23:02:34 +02:00
Fix/catchup (#778)
* Fix: setting auth web_ui token_ttl_mins = 0 fixed. * Fix: catchup * New: added playlist update log view * Fix: Landing Page New: "Last Page" added for Landing Page selection
This commit is contained in:
+361
-77
@@ -506,7 +506,7 @@ pub(crate) fn mark_response_as_uncompressed<B>(response: &mut Response<B>) {
|
||||
response.extensions_mut().insert(DisableResponseCompression);
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
#[cfg(test)]
|
||||
pub(crate) fn should_compress_response<B>(response: &Response<B>) -> bool {
|
||||
should_compress_response_extensions(response.extensions())
|
||||
}
|
||||
@@ -724,6 +724,7 @@ struct StreamingAcquireOptions<'a> {
|
||||
user_priority: i8,
|
||||
connection_kind: crate::api::model::ConnectionKind,
|
||||
session_owner: Option<&'a str>,
|
||||
accept_requested_stream_url: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn connection_priority_for_kind(user: &ProxyUserCredentials, kind: crate::api::model::ConnectionKind) -> i8 {
|
||||
@@ -784,9 +785,9 @@ pub(crate) struct GraceResolutionContext {
|
||||
/// The original `ConnectionKind` from the admission decision that led to this grace.
|
||||
/// Preserved so that the remaining-strategy fallback can return the correct kind
|
||||
/// (e.g., `Soft`) even when the grace itself hardcoded `Normal`.
|
||||
#[allow(dead_code)]
|
||||
// Stored so the original admission kind remains available when follow-up
|
||||
// grace fallback reconstruction starts using it again.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) kind: Option<crate::api::model::ConnectionKind>,
|
||||
}
|
||||
|
||||
@@ -967,6 +968,8 @@ pub(in crate::api) async fn resolve_admission_with_strategies(
|
||||
kind: admission.kind,
|
||||
};
|
||||
|
||||
let _admission_guard = app_state.active_users.acquire_user_admission(username).await;
|
||||
|
||||
if let Some(resolution) = evaluate_admission_strategy_loop(
|
||||
app_state,
|
||||
username,
|
||||
@@ -1048,6 +1051,8 @@ pub(in crate::api) async fn evaluate_remaining_strategies_after_grace(
|
||||
kind: original_kind,
|
||||
};
|
||||
|
||||
let _admission_guard = app_state.active_users.acquire_user_admission(username).await;
|
||||
|
||||
if let Some(resolution) = evaluate_admission_strategy_loop(
|
||||
app_state,
|
||||
username,
|
||||
@@ -1125,10 +1130,11 @@ async fn activate_session_before_stream_open(
|
||||
// If caller passes FollowUp, verify the session is still counted under the guard.
|
||||
// A stale FollowUp would bypass admission — reclassify to catch this.
|
||||
let effective_request_class = if let Some(request_class) = request_class {
|
||||
if request_class == PlaybackRequestClass::FollowUp {
|
||||
// Re-read session under the guard to ensure the counted lease is still held.
|
||||
if matches!(request_class, PlaybackRequestClass::FollowUp | PlaybackRequestClass::Activate) {
|
||||
// Re-read session under the guard to ensure the counted lease is still held or acquired.
|
||||
// If it is no longer counted, classify it from the current lifecycle so
|
||||
// stale FollowUp requests cannot bypass admission.
|
||||
// If it became counted, classify it so stale Activate requests don't double count.
|
||||
let current_session = app_state
|
||||
.active_users
|
||||
.get_and_update_user_session(&user.username, session_token)
|
||||
@@ -1193,8 +1199,13 @@ async fn activate_session_before_stream_open(
|
||||
}, Some(crate::api::model::GraceMode::Hold))
|
||||
}
|
||||
Some(crate::api::model::PlaybackLifecycle::GraceActive) => {
|
||||
// Already in GraceActive — nothing to refresh.
|
||||
(crate::api::model::PlaybackLifecycle::GraceActive, Some(crate::api::model::GraceMode::Instant))
|
||||
// Already in GraceActive — infer mode from item_type.
|
||||
let mode = if item_type.is_live() || item_type.is_live_adaptive() {
|
||||
crate::api::model::GraceMode::Hold
|
||||
} else {
|
||||
crate::api::model::GraceMode::Instant
|
||||
};
|
||||
(crate::api::model::PlaybackLifecycle::GraceActive, Some(mode))
|
||||
}
|
||||
_ => {
|
||||
// Session not yet in grace state — infer from item_type defaults.
|
||||
@@ -1349,10 +1360,10 @@ fn get_stream_alternative_url_m3u(
|
||||
input: &ConfigInput,
|
||||
alias_input: &Arc<ProviderConfig>,
|
||||
) -> Option<String> {
|
||||
let alt_input_user_info = alias_input.get_user_info()?;
|
||||
if let Some((source_base_url, source_username, source_password)) =
|
||||
find_input_account_by_signature(stream_url, input)
|
||||
{
|
||||
let alt_input_user_info = alias_input.get_user_info()?;
|
||||
let modified = stream_url.replacen(&source_base_url, &alt_input_user_info.base_url, 1);
|
||||
let mut url = Url::parse(&modified).ok()?;
|
||||
|
||||
@@ -1371,7 +1382,7 @@ fn get_stream_alternative_url_m3u(
|
||||
|
||||
return Some(url.to_string());
|
||||
}
|
||||
if stream_url_has_account_signature(stream_url) {
|
||||
if stream_url_has_account_signature(stream_url, &alt_input_user_info) {
|
||||
return None;
|
||||
}
|
||||
Some(stream_url.to_string())
|
||||
@@ -1461,6 +1472,18 @@ fn rewrite_query_auth_fields(url: &mut Url, new_username: &str, new_password: &s
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_path_segments(url: &Url) -> Option<Vec<String>> {
|
||||
url.path_segments()
|
||||
.map(|segments| segments.map(ToOwned::to_owned).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn find_path_auth_segment_index(segments: &[String], username: &str, password: &str) -> Option<usize> {
|
||||
segments.windows(2).position(|pair| {
|
||||
pair.first().is_some_and(|segment| segment == username)
|
||||
&& pair.get(1).is_some_and(|segment| segment == password)
|
||||
})
|
||||
}
|
||||
|
||||
fn rewrite_path_auth_fields(
|
||||
url: &mut Url,
|
||||
old_username: &str,
|
||||
@@ -1468,28 +1491,11 @@ fn rewrite_path_auth_fields(
|
||||
new_username: &str,
|
||||
new_password: &str,
|
||||
) -> bool {
|
||||
let Some(mut segments) = url
|
||||
.path_segments()
|
||||
.map(|segments| segments.map(ToOwned::to_owned).collect::<Vec<_>>())
|
||||
else {
|
||||
let Some(mut segments) = collect_path_segments(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let credential_index = if segments.len() >= 3
|
||||
&& matches!(segments.first().map(String::as_str), Some("live" | "movie" | "series"))
|
||||
&& segments.get(1).is_some_and(|segment| segment == old_username)
|
||||
&& segments.get(2).is_some_and(|segment| segment == old_password)
|
||||
{
|
||||
Some(1)
|
||||
} else if segments.len() >= 2
|
||||
&& segments.first().is_some_and(|segment| segment == old_username)
|
||||
&& segments.get(1).is_some_and(|segment| segment == old_password)
|
||||
{
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let credential_index = find_path_auth_segment_index(&segments, old_username, old_password);
|
||||
let Some(credential_index) = credential_index else {
|
||||
return false;
|
||||
};
|
||||
@@ -1518,7 +1524,7 @@ fn stream_url_matches_provider(stream_url: &str, provider_cfg: &ProviderConfig)
|
||||
// For M3U inputs, the stored playlist entry itself is the trust anchor.
|
||||
// Open external URLs are therefore allowed, but external URLs that carry
|
||||
// explicit account markers must still match the selected provider account.
|
||||
if stream_url_has_account_signature(stream_url) {
|
||||
if stream_url_has_account_signature(stream_url, &user_info) {
|
||||
return stream_url_account_matches(stream_url, &user_info);
|
||||
}
|
||||
true
|
||||
@@ -1553,18 +1559,14 @@ fn stream_url_account_matches(stream_url: &str, user_info: &crate::model::InputU
|
||||
return has_query_username && has_query_password;
|
||||
}
|
||||
|
||||
let mut segments = url.path_segments().into_iter().flatten();
|
||||
let first = segments.next();
|
||||
let (username, password) = match first {
|
||||
Some("live" | "movie" | "series") => (segments.next(), segments.next()),
|
||||
Some(username) => (Some(username), segments.next()),
|
||||
None => return false,
|
||||
let Some(segments) = collect_path_segments(&url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
username == Some(user_info.username.as_str()) && password == Some(user_info.password.as_str())
|
||||
find_path_auth_segment_index(&segments, &user_info.username, &user_info.password).is_some()
|
||||
}
|
||||
|
||||
fn stream_url_has_account_signature(stream_url: &str) -> bool {
|
||||
fn stream_url_has_account_signature(stream_url: &str, user_info: &crate::model::InputUserInfo) -> bool {
|
||||
let Ok(url) = Url::parse(stream_url) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1586,6 +1588,17 @@ fn stream_url_has_account_signature(stream_url: &str) -> bool {
|
||||
if has_query_username || has_query_password {
|
||||
return has_query_username && has_query_password;
|
||||
}
|
||||
|
||||
// Path-based credentials: some Xtream endpoints embed the account in the URL
|
||||
// path (e.g. /live/<user>/<pass>/...). Only flag a signature when the
|
||||
// consecutive segments actually match the configured user/pass — arbitrary
|
||||
// open paths must not be treated as account signatures.
|
||||
if let Some(segments) = collect_path_segments(&url) {
|
||||
if find_path_auth_segment_index(&segments, &user_info.username, &user_info.password).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1593,7 +1606,11 @@ fn select_provider_stream_url(
|
||||
stream_url: &str,
|
||||
input: &ConfigInput,
|
||||
provider_cfg: &Arc<ProviderConfig>,
|
||||
accept_requested_stream_url: bool,
|
||||
) -> Option<(Arc<str>, String)> {
|
||||
if accept_requested_stream_url {
|
||||
return Some((provider_cfg.name.clone(), stream_url.to_string()));
|
||||
}
|
||||
if stream_url_matches_provider(stream_url, provider_cfg) {
|
||||
Some((provider_cfg.name.clone(), stream_url.to_string()))
|
||||
} else {
|
||||
@@ -1729,6 +1746,7 @@ async fn resolve_streaming_strategy(
|
||||
options: StreamingAcquireOptions<'_>,
|
||||
) -> StreamingStrategy {
|
||||
// allocate a provider connection
|
||||
let accept_requested_stream_url = options.accept_requested_stream_url;
|
||||
let mut provider_connection_handle = acquire_stream_provider_handle(app_state, input, fingerprint, options).await;
|
||||
|
||||
// panel_api provisioning/loading is handled later in the stream creation flow
|
||||
@@ -1744,7 +1762,12 @@ async fn resolve_streaming_strategy(
|
||||
ProviderAllocation::Available(ref provider_cfg) | ProviderAllocation::GracePeriod(ref provider_cfg) => {
|
||||
// Keep the URL only when it already targets the selected provider account. Hot reload can leave old
|
||||
// alias URLs in persisted playlists until the next processing run.
|
||||
if let Some((selected_provider_name, url)) = select_provider_stream_url(stream_url, input, provider_cfg) {
|
||||
if let Some((selected_provider_name, url)) = select_provider_stream_url(
|
||||
stream_url,
|
||||
input,
|
||||
provider_cfg,
|
||||
accept_requested_stream_url,
|
||||
) {
|
||||
debug_if_enabled!(
|
||||
"provider session: input={} provider_cfg={} user={} allocation={} stream_url={}",
|
||||
sanitize_sensitive_info(&input.name),
|
||||
@@ -1782,7 +1805,10 @@ async fn resolve_streaming_strategy(
|
||||
|
||||
if release_failed_mapping {
|
||||
if let Some(handle) = provider_connection_handle.take() {
|
||||
app_state.connection_manager.release_provider_handle(Some(handle)).await;
|
||||
let connection_manager = Arc::clone(&app_state.connection_manager);
|
||||
tokio::spawn(async move {
|
||||
connection_manager.release_provider_handle(Some(handle)).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1850,6 +1876,8 @@ async fn create_stream_response_details(
|
||||
connection_kind: crate::api::model::ConnectionKind,
|
||||
is_reopen: bool,
|
||||
session_owner: Option<&str>,
|
||||
session_headers: Option<&HashMap<String, String>>,
|
||||
accept_requested_stream_url: bool,
|
||||
grace_hold_override: Option<bool>,
|
||||
grace_resolution_context: Option<crate::api::api_utils::GraceResolutionContext>,
|
||||
) -> Result<StreamDetails, TuliproxError> {
|
||||
@@ -1865,6 +1893,7 @@ async fn create_stream_response_details(
|
||||
user_priority,
|
||||
connection_kind,
|
||||
session_owner,
|
||||
accept_requested_stream_url,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1915,6 +1944,7 @@ async fn create_stream_response_details(
|
||||
stream_info,
|
||||
provider_name: Some(provider_name),
|
||||
request_url: None,
|
||||
session_headers: session_headers.cloned(),
|
||||
grace_period: grace_period_options,
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
@@ -1972,6 +2002,7 @@ async fn create_stream_response_details(
|
||||
stream_url: &url,
|
||||
req_headers,
|
||||
input_headers: streaming_strategy.input_headers.as_ref(),
|
||||
session_headers,
|
||||
disabled_headers: disabled_headers.as_ref(),
|
||||
default_user_agent: default_user_agent.as_deref(),
|
||||
username: Some(username),
|
||||
@@ -2012,12 +2043,15 @@ async fn create_stream_response_details(
|
||||
}
|
||||
}
|
||||
|
||||
// If no upstream stream is ready, release the provider unless provider grace
|
||||
// intentionally deferred the open until the grace check resolves.
|
||||
let provider_handle = if stream.is_none() && !defer_provider_stream_until_grace_check {
|
||||
// If no upstream stream is ready, release the provider.
|
||||
// Even if provider grace intentionally deferred the open, we must release the handle
|
||||
// here because the deferred open context will acquire a fresh slot when it resumes.
|
||||
let provider_handle = if stream.is_none() {
|
||||
let provider_handle = streaming_strategy.provider_handle.take();
|
||||
app_state.connection_manager.release_provider_handle(provider_handle).await;
|
||||
error!("Can't open stream {}", sanitize_sensitive_info(&request_url));
|
||||
if !defer_provider_stream_until_grace_check {
|
||||
error!("Can't open stream {}", sanitize_sensitive_info(&request_url));
|
||||
}
|
||||
None
|
||||
} else {
|
||||
streaming_strategy.provider_handle.take()
|
||||
@@ -2028,6 +2062,7 @@ async fn create_stream_response_details(
|
||||
stream_info,
|
||||
provider_name: guard_provider_name.clone(),
|
||||
request_url: Some(request_url.clone()),
|
||||
session_headers: session_headers.cloned(),
|
||||
grace_period: grace_period_options,
|
||||
provider_grace_active,
|
||||
disable_provider_grace: false,
|
||||
@@ -2347,6 +2382,8 @@ pub async fn force_provider_stream_response(
|
||||
connection_kind,
|
||||
true,
|
||||
Some(user_session.token.as_str()),
|
||||
Some(&user_session.provider_session_headers),
|
||||
true,
|
||||
grace_mode.map(|mode| matches!(mode, crate::api::model::GraceMode::Hold)),
|
||||
None,
|
||||
)
|
||||
@@ -2577,6 +2614,10 @@ pub(crate) async fn stream_response(
|
||||
}
|
||||
|
||||
let stream_options = get_stream_options(app_state);
|
||||
let session_state = app_state
|
||||
.active_users
|
||||
.get_and_update_user_session(&user.username, session_token)
|
||||
.await;
|
||||
let mut stream_details = match create_stream_response_details(
|
||||
app_state,
|
||||
&stream_options,
|
||||
@@ -2597,6 +2638,8 @@ pub(crate) async fn stream_response(
|
||||
connection_kind,
|
||||
false,
|
||||
Some(session_token),
|
||||
session_state.as_ref().map(|session| &session.provider_session_headers),
|
||||
pinned_provider.is_some(),
|
||||
grace_mode.map(|m| matches!(m, crate::api::model::GraceMode::Hold)),
|
||||
activation.grace_context.clone(),
|
||||
)
|
||||
@@ -2656,6 +2699,11 @@ pub(crate) async fn stream_response(
|
||||
)
|
||||
.await;
|
||||
|
||||
// Captured before `stream_details` is moved into `create_active_client_stream`.
|
||||
// The pinning rule is centralized in `should_pin_provider_for_session` so it stays
|
||||
// testable in isolation and in sync with the call site below.
|
||||
let should_pin_provider = should_pin_provider_for_session(&stream_details, app_state, item_type);
|
||||
|
||||
let mut is_stream_shared = share_stream && !stream_details.has_deferred_provider_open();
|
||||
if let Some((_header, _status_code, _url, Some(_custom_video))) = stream_details.stream_info.as_ref() {
|
||||
if stream_details.stream.is_some() {
|
||||
@@ -2797,12 +2845,14 @@ pub(crate) async fn stream_response(
|
||||
socket_bound,
|
||||
})
|
||||
.await;
|
||||
let reservation_ttl_secs = get_session_reservation_ttl_secs(app_state, item_type);
|
||||
if reservation_ttl_secs > 0 {
|
||||
app_state
|
||||
.active_provider
|
||||
.refresh_provider_reservation(&provider, session_token, reservation_ttl_secs)
|
||||
.await;
|
||||
if should_pin_provider {
|
||||
let reservation_ttl_secs = get_session_reservation_ttl_secs(app_state, item_type);
|
||||
if reservation_ttl_secs > 0 {
|
||||
app_state
|
||||
.active_provider
|
||||
.refresh_provider_reservation(&provider, session_token, reservation_ttl_secs)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2862,18 +2912,17 @@ async fn prepare_stream_metering(
|
||||
return StreamMeteringConfig::default();
|
||||
}
|
||||
|
||||
if share_stream && !has_stream && !has_deferred_provider_open {
|
||||
if share_stream {
|
||||
let meter_uid = app_state
|
||||
.shared_stream_manager
|
||||
.get_or_register_meter_uid(stream_url, || app_state.connection_manager.next_stream_uid())
|
||||
.await;
|
||||
return StreamMeteringConfig {
|
||||
meter_uid: app_state.shared_stream_manager.get_meter_uid(stream_url).await.unwrap_or(0),
|
||||
meter_stream: false,
|
||||
meter_uid,
|
||||
meter_stream: has_stream || has_deferred_provider_open,
|
||||
};
|
||||
}
|
||||
|
||||
if has_stream || has_deferred_provider_open {
|
||||
} else if has_stream || has_deferred_provider_open {
|
||||
let meter_uid = app_state.connection_manager.next_stream_uid();
|
||||
if share_stream {
|
||||
app_state.shared_stream_manager.register_meter_uid(stream_url, meter_uid).await;
|
||||
}
|
||||
return StreamMeteringConfig { meter_uid, meter_stream: true };
|
||||
}
|
||||
|
||||
@@ -2928,6 +2977,29 @@ pub(crate) fn get_session_reservation_ttl_secs(app_state: &Arc<AppState>, item_t
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the session should pin the provider account via `refresh_provider_reservation`.
|
||||
///
|
||||
/// A non-Provisioning custom video (`ChannelUnavailable`, `ProviderConnectionsExhausted`, …) means
|
||||
/// the upstream open already failed. The provider connection slot was released by
|
||||
/// `create_provider_stream`, and the custom video is a local fallback served to the client.
|
||||
/// Pinning the provider via `refresh_provider_reservation` would hold the provider account for
|
||||
/// the configured session TTL (e.g. `catchup_session_ttl_secs`), blocking other sessions of
|
||||
/// the same family from using it even though the slot is already free.
|
||||
///
|
||||
/// Only `Provisioning` custom videos represent a real provider handoff that benefits from
|
||||
/// keeping the same provider pinned, and real provider streams (`stream_info` carries no
|
||||
/// `CustomVideoStreamType`) obviously qualify.
|
||||
pub(crate) fn should_pin_provider_for_session(
|
||||
stream_details: &StreamDetails,
|
||||
_app_state: &Arc<AppState>,
|
||||
_item_type: PlaylistItemType,
|
||||
) -> bool {
|
||||
!matches!(
|
||||
stream_details.stream_info.as_ref(),
|
||||
Some((_, _, _, Some(cv))) if *cv != CustomVideoStreamType::Provisioning
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn try_shared_stream_response_if_any(
|
||||
app_state: &Arc<AppState>,
|
||||
@@ -2940,10 +3012,6 @@ async fn try_shared_stream_response_if_any(
|
||||
session_token: &str,
|
||||
req_headers: &HeaderMap,
|
||||
) -> Option<impl IntoResponse> {
|
||||
if connect_permission == UserConnectionPermission::GracePeriod {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some((stream, provider)) = SharedStreamManager::subscribe_shared_stream(
|
||||
app_state,
|
||||
stream_url,
|
||||
@@ -2983,10 +3051,13 @@ async fn try_shared_stream_response_if_any(
|
||||
}
|
||||
stream_channel.shared = true;
|
||||
stream_channel.shared_joined_existing = Some(true);
|
||||
stream_channel.shared_stream_id =
|
||||
app_state.shared_stream_manager.get_meter_uid(stream_url).await.map(u64::from);
|
||||
let meter_uid = app_state
|
||||
.shared_stream_manager
|
||||
.get_or_register_meter_uid(stream_url, || app_state.connection_manager.next_stream_uid())
|
||||
.await;
|
||||
stream_channel.shared_stream_id = Some(u64::from(meter_uid));
|
||||
let metering = StreamMeteringConfig {
|
||||
meter_uid: app_state.shared_stream_manager.get_meter_uid(stream_url).await.unwrap_or(0),
|
||||
meter_uid,
|
||||
meter_stream: false,
|
||||
};
|
||||
let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams {
|
||||
@@ -3087,8 +3158,11 @@ pub(crate) async fn local_stream_response(
|
||||
|
||||
let path = PathBuf::from(pli.url.strip_prefix("file://").unwrap_or(&pli.url));
|
||||
|
||||
let Ok(mut file) = tokio::fs::File::open(&path).await else { return StatusCode::NOT_FOUND.into_response() };
|
||||
let Ok(opened_metadata) = file.metadata().await else { return internal_server_error!() };
|
||||
|
||||
// Canonicalize and validate the path
|
||||
let path = match tokio::fs::canonicalize(&path).await {
|
||||
let canonical = match tokio::fs::canonicalize(&path).await {
|
||||
Ok(canonical) => canonical,
|
||||
Err(err) => {
|
||||
error!("Local file path is corrupt {}: {err}", path.display());
|
||||
@@ -3097,6 +3171,17 @@ pub(crate) async fn local_stream_response(
|
||||
};
|
||||
|
||||
if check_path {
|
||||
let Ok(canonical_metadata) = tokio::fs::metadata(&canonical).await else { return internal_server_error!() };
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if opened_metadata.dev() != canonical_metadata.dev() || opened_metadata.ino() != canonical_metadata.ino() {
|
||||
error!("TOCTOU race detected: file swapped during local_stream_response");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(library_paths) = app_state
|
||||
.app_config
|
||||
.config
|
||||
@@ -3110,14 +3195,12 @@ pub(crate) async fn local_stream_response(
|
||||
|
||||
// Verify path is within allowed media directories
|
||||
// (requires configuration of allowed base paths)
|
||||
if !is_path_within_allowed_directories(&path, &library_paths) {
|
||||
if !is_path_within_allowed_directories(&canonical, &library_paths) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(mut file) = tokio::fs::File::open(&path).await else { return StatusCode::NOT_FOUND.into_response() };
|
||||
let Ok(metadata) = file.metadata().await else { return internal_server_error!() };
|
||||
let file_size = metadata.len();
|
||||
let file_size = opened_metadata.len();
|
||||
|
||||
let range = req_headers.get("range").and_then(|v| v.to_str().ok()).and_then(parse_range);
|
||||
|
||||
@@ -3716,8 +3799,8 @@ pub(crate) fn create_playback_session_fingerprint(
|
||||
// Adaptive playlist starts need a per-initial-socket token so two players behind
|
||||
// the same IP/UA can watch the same HLS/DASH stream independently. The created
|
||||
// UserSession itself can still be non-socket-bound.
|
||||
let socket_bound =
|
||||
is_socket_bound_playback_session(item_type, extension) || is_session_based_playback(item_type, extension);
|
||||
let session_bound = is_session_based_playback(item_type, extension);
|
||||
let socket_bound = !session_bound && is_socket_bound_playback_session(item_type, extension);
|
||||
create_session_fingerprint(fingerprint, username, virtual_id, socket_bound)
|
||||
}
|
||||
|
||||
@@ -3945,7 +4028,7 @@ pub fn empty_json_response_as_array() -> axum::http::Result<axum::response::Resp
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::StreamHistoryConfig;
|
||||
use crate::model::{GracePeriodOptions, StreamHistoryConfig};
|
||||
use crate::{
|
||||
api::model::{
|
||||
ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, EventManager,
|
||||
@@ -4097,10 +4180,26 @@ mod tests {
|
||||
"http://same.example/live/selected-user/selected-pass/123.ts",
|
||||
&provider
|
||||
));
|
||||
assert!(stream_url_matches_provider(
|
||||
"http://same.example/timeshift/selected-user/selected-pass/30/2026-06-15:20-00/123.ts",
|
||||
&provider
|
||||
));
|
||||
assert!(stream_url_matches_provider(
|
||||
"http://same.example/future-route/selected-user/selected-pass/opaque/123.ts",
|
||||
&provider
|
||||
));
|
||||
assert!(!stream_url_matches_provider(
|
||||
"http://same.example/live/other-user/other-pass/123.ts",
|
||||
&provider
|
||||
));
|
||||
assert!(!stream_url_matches_provider(
|
||||
"http://same.example/timeshift/other-user/other-pass/30/2026-06-15:20-00/123.ts",
|
||||
&provider
|
||||
));
|
||||
assert!(!stream_url_matches_provider(
|
||||
"http://same.example/future-route/other-user/other-pass/opaque/123.ts",
|
||||
&provider
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4151,6 +4250,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_url_matches_provider_detects_m3u_path_credentials_against_alias_account() {
|
||||
// Regression: a cross-host M3U URL whose path embeds the alias's
|
||||
// account credentials must be detected as an account signature and
|
||||
// validated, not silently allowed as an open URL.
|
||||
let provider = test_runtime_provider_with_type(
|
||||
"http://provider.example",
|
||||
"selected-user",
|
||||
"selected-pass",
|
||||
InputType::M3u,
|
||||
);
|
||||
|
||||
// Matching path credentials -> allowed (account matches).
|
||||
assert!(stream_url_matches_provider(
|
||||
"http://cdn.example/live/selected-user/selected-pass/123.ts",
|
||||
&provider
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_url_matches_provider_rejects_open_external_cdn_url_without_account_signature_for_xtream() {
|
||||
let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass");
|
||||
@@ -4248,6 +4366,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_stream_alternative_url_rewrites_timeshift_path_credentials_for_alias_account() {
|
||||
let input = ConfigInput {
|
||||
name: "source".intern(),
|
||||
url: "http://provider.example".to_string(),
|
||||
username: Some("source-user".to_string()),
|
||||
password: Some("source-pass".to_string()),
|
||||
input_type: InputType::Xtream,
|
||||
..ConfigInput::default()
|
||||
};
|
||||
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
|
||||
let stream_url = "http://provider.example/timeshift/source-user/source-pass/30/2026-06-15:20-00/123.ts";
|
||||
|
||||
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
Some("http://alias.example/timeshift/alias-user/alias-pass/30/2026-06-15:20-00/123.ts".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_stream_alternative_url_rewrites_future_route_path_credentials_for_alias_account() {
|
||||
let input = ConfigInput {
|
||||
name: "source".intern(),
|
||||
url: "http://provider.example".to_string(),
|
||||
username: Some("source-user".to_string()),
|
||||
password: Some("source-pass".to_string()),
|
||||
input_type: InputType::Xtream,
|
||||
..ConfigInput::default()
|
||||
};
|
||||
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
|
||||
let stream_url = "http://provider.example/future-route/source-user/source-pass/opaque/123.ts";
|
||||
|
||||
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
Some("http://alias.example/future-route/alias-user/alias-pass/opaque/123.ts".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_stream_alternative_url_keeps_open_external_playlist_url_for_m3u() {
|
||||
let input = ConfigInput {
|
||||
@@ -4483,6 +4641,7 @@ mod tests {
|
||||
user_priority: 0,
|
||||
connection_kind: crate::api::model::ConnectionKind::Normal,
|
||||
session_owner: Some("vod-session"),
|
||||
accept_requested_stream_url: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -4504,6 +4663,7 @@ mod tests {
|
||||
user_priority: 0,
|
||||
connection_kind: crate::api::model::ConnectionKind::Normal,
|
||||
session_owner: Some("live-session"),
|
||||
accept_requested_stream_url: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -4544,6 +4704,7 @@ mod tests {
|
||||
user_priority: 0,
|
||||
connection_kind: crate::api::model::ConnectionKind::Normal,
|
||||
session_owner: Some("live-session"),
|
||||
accept_requested_stream_url: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -4582,6 +4743,7 @@ mod tests {
|
||||
user_priority: 0,
|
||||
connection_kind: crate::api::model::ConnectionKind::Normal,
|
||||
session_owner: Some("live-session"),
|
||||
accept_requested_stream_url: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -4592,6 +4754,45 @@ mod tests {
|
||||
app_state.active_provider.release_connection(&addr).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_streaming_strategy_accepts_session_requested_stream_url() {
|
||||
let app_state = create_test_dual_provider_app_state();
|
||||
let input_name = "provider_1".intern();
|
||||
let input = app_state
|
||||
.app_config
|
||||
.sources
|
||||
.load()
|
||||
.get_input_by_name(&input_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| unreachable!());
|
||||
let addr: SocketAddr = "127.0.0.1:55306".parse().unwrap_or_else(|_| unreachable!());
|
||||
let trusted_url = "http://unmapped.example/live/user1/pass1/100.ts";
|
||||
let strategy = resolve_streaming_strategy(
|
||||
&app_state,
|
||||
trusted_url,
|
||||
&create_test_fingerprint(addr),
|
||||
&input,
|
||||
StreamingAcquireOptions {
|
||||
force_provider: None,
|
||||
allow_forced_provider_fallback: false,
|
||||
allow_provider_grace: false,
|
||||
user_priority: 0,
|
||||
connection_kind: crate::api::model::ConnectionKind::Normal,
|
||||
session_owner: Some("live-session"),
|
||||
accept_requested_stream_url: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else {
|
||||
panic!("session-requested URL should be accepted for the pinned provider")
|
||||
};
|
||||
assert_eq!(provider.as_ref(), "provider_1");
|
||||
assert_eq!(url.as_ref(), trusted_url);
|
||||
|
||||
app_state.active_provider.release_connection(&addr).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_allow_exhausted_shared_reconnect_only_for_matching_shared_session() {
|
||||
let session = UserSession {
|
||||
@@ -4601,6 +4802,7 @@ mod tests {
|
||||
virtual_id: 282,
|
||||
provider: Arc::<str>::from("provider"),
|
||||
stream_url: Arc::<str>::from("http://provider/live/449924.ts"),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr: "127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!()),
|
||||
socket_bound: false,
|
||||
active_addrs: vec!["127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!())],
|
||||
@@ -4795,7 +4997,7 @@ mod tests {
|
||||
|
||||
let tokens = CancelTokens::default();
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets {
|
||||
@@ -4908,6 +5110,7 @@ mod tests {
|
||||
PlaylistItemType::LiveHls => "http://provider-1.example/live/42.m3u8",
|
||||
_ => "http://provider-1.example/live/42.ts",
|
||||
}),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr: "127.0.0.1:55555".parse().unwrap_or_else(|_| unreachable!()),
|
||||
socket_bound: item_type.uses_socket_bound_session(),
|
||||
active_addrs: Vec::new(),
|
||||
@@ -7447,6 +7650,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for: when a catchup request fails upstream (e.g. provider returns
|
||||
/// 4xx/5xx) the connection-slot is released, but the provider account was being
|
||||
/// pinned via `refresh_provider_reservation` for `catchup_session_ttl_secs`. This
|
||||
/// blocked other sessions of the same family from acquiring the same provider even
|
||||
/// though the slot was already free. The fix delegates the pinning decision to
|
||||
/// `should_pin_provider_for_session` and skips the reservation when the response
|
||||
/// is a non-Provisioning custom video (failure fallback). Provisioning custom videos
|
||||
/// must keep their reservation since they represent a successful provider handoff.
|
||||
#[tokio::test]
|
||||
async fn should_pin_provider_for_session_skips_reservation_on_failure_custom_video() {
|
||||
let app_state = create_test_app_state();
|
||||
let no_video_details = StreamDetails {
|
||||
stream: None,
|
||||
stream_info: Some((Vec::new(), StatusCode::OK, None, None)),
|
||||
provider_name: Some("provider_1".intern()),
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions::default(),
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
grace_resolution_context: None,
|
||||
};
|
||||
assert!(
|
||||
should_pin_provider_for_session(&no_video_details, &app_state, PlaylistItemType::Catchup),
|
||||
"a real provider stream (no CustomVideoStreamType) must pin the provider"
|
||||
);
|
||||
|
||||
let provisioning_details = StreamDetails {
|
||||
stream: None,
|
||||
stream_info: Some((
|
||||
Vec::new(),
|
||||
StatusCode::OK,
|
||||
None,
|
||||
Some(CustomVideoStreamType::Provisioning),
|
||||
)),
|
||||
provider_name: Some("provider_1".intern()),
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions::default(),
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
grace_resolution_context: None,
|
||||
};
|
||||
assert!(
|
||||
should_pin_provider_for_session(&provisioning_details, &app_state, PlaylistItemType::Catchup),
|
||||
"a Provisioning custom video represents a successful provider handoff and must pin"
|
||||
);
|
||||
|
||||
for failure_type in [
|
||||
CustomVideoStreamType::ChannelUnavailable,
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
CustomVideoStreamType::UserAccountExpired,
|
||||
CustomVideoStreamType::LowPriorityPreempted,
|
||||
] {
|
||||
let failure_details = StreamDetails {
|
||||
stream: None,
|
||||
stream_info: Some((Vec::new(), StatusCode::BAD_REQUEST, None, Some(failure_type))),
|
||||
provider_name: Some("provider_1".intern()),
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions::default(),
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
reconnect_flag: None,
|
||||
provider_handle: None,
|
||||
grace_resolution_context: None,
|
||||
};
|
||||
assert!(
|
||||
!should_pin_provider_for_session(&failure_details, &app_state, PlaylistItemType::Catchup),
|
||||
"{failure_type:?} is a failure fallback — must NOT pin the provider"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_stream_response_disables_response_compression() {
|
||||
let app_state = create_test_app_state();
|
||||
@@ -8102,7 +8384,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_playback_session_fingerprint_is_unique_per_initial_socket() {
|
||||
fn adaptive_playback_session_fingerprint_is_logical_across_initial_sockets() {
|
||||
let Some(first_addr) = "127.0.0.1:55177".parse().ok() else {
|
||||
return;
|
||||
};
|
||||
@@ -8115,9 +8397,10 @@ mod tests {
|
||||
let first_token = create_playback_session_fingerprint(&first, "user1", 7002, PlaylistItemType::Live, Some(HLS_EXT));
|
||||
let second_token = create_playback_session_fingerprint(&second, "user1", 7002, PlaylistItemType::Live, Some(HLS_EXT));
|
||||
|
||||
assert_ne!(first_token, second_token);
|
||||
assert!(first_token.contains(&first.addr.to_string()));
|
||||
assert!(second_token.contains(&second.addr.to_string()));
|
||||
assert_eq!(first_token, second_token);
|
||||
assert!(first_token.contains(&first.key));
|
||||
assert!(!first_token.contains(&first.addr.to_string()));
|
||||
assert!(!second_token.contains(&second.addr.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -8355,6 +8638,7 @@ mod tests {
|
||||
virtual_id: 9001,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/movie.mkv".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr: seek,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![primary, overlap, seek, overlap],
|
||||
|
||||
@@ -155,7 +155,7 @@ mod tests {
|
||||
downloads: CancellationToken::new(),
|
||||
};
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<crate::model::ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(crate::model::ProcessTargets {
|
||||
|
||||
@@ -1988,7 +1988,7 @@ mod tests {
|
||||
|
||||
let tokens = CancelTokens::default();
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets {
|
||||
|
||||
@@ -24,7 +24,7 @@ use shared::{
|
||||
model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster},
|
||||
utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, Internable, CUSTOM_VIDEO_PREFIX, HLS_EXT},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use url::Url;
|
||||
use shared::model::ConnectFailureReason;
|
||||
use crate::auth::check_network_access_only;
|
||||
@@ -100,6 +100,22 @@ fn hls_response(hls_content: String) -> impl IntoResponse + Send {
|
||||
.body(hls_content))
|
||||
}
|
||||
|
||||
fn extract_hls_provider_session_headers(headers: &HeaderMap) -> HashMap<String, String> {
|
||||
let cookies = headers
|
||||
.get_all(axum::http::header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.filter_map(|value| value.split(';').next().map(str::trim))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut session_headers = HashMap::new();
|
||||
if !cookies.is_empty() {
|
||||
session_headers.insert(String::from("cookie"), cookies.join("; "));
|
||||
}
|
||||
session_headers
|
||||
}
|
||||
|
||||
async fn release_prepared_hls_manifest_session(
|
||||
app_state: &Arc<AppState>,
|
||||
username: &str,
|
||||
@@ -321,29 +337,27 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
let input_source = InputSource::from(input).with_url(request_url);
|
||||
let use_manual_redirects = app_state.should_use_manual_redirects();
|
||||
let download_result = if use_manual_redirects {
|
||||
request::download_text_content_with_manual_redirects(
|
||||
request::download_text_content_with_manual_redirects_and_headers(
|
||||
&app_state.app_config,
|
||||
&app_state.http_client_no_redirect.load(),
|
||||
&input_source,
|
||||
Some(&headers),
|
||||
None,
|
||||
false,
|
||||
MAX_MANUAL_REDIRECTS,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
request::download_text_content(
|
||||
request::download_text_content_with_headers(
|
||||
&app_state.app_config,
|
||||
&app_state.http_client.load(),
|
||||
&input_source,
|
||||
Some(&headers),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
};
|
||||
match download_result {
|
||||
Ok((content, response_url)) => {
|
||||
Ok((content, response_url, response_headers)) => {
|
||||
let encrypt_secret = app_state.get_encrypt_secret();
|
||||
let base_url = server_info.get_base_url();
|
||||
let rewrite_hls_props = RewriteHlsProps {
|
||||
@@ -358,6 +372,13 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
};
|
||||
let hls_content = rewrite_hls(user, &rewrite_hls_props);
|
||||
if let Some(session_token) = session_token.as_deref() {
|
||||
let session_headers = extract_hls_provider_session_headers(&response_headers);
|
||||
if !session_headers.is_empty() {
|
||||
app_state
|
||||
.active_users
|
||||
.update_session_provider_headers(&user.username, session_token, &session_headers)
|
||||
.await;
|
||||
}
|
||||
release_prepared_hls_manifest_session(app_state, &user.username, session_token, &fingerprint.addr).await;
|
||||
}
|
||||
hls_response(hls_content).into_response()
|
||||
@@ -714,7 +735,8 @@ pub fn hls_api_register() -> axum::Router<Arc<AppState>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::m3u_archive_epg_reference_ts;
|
||||
use super::{extract_hls_provider_session_headers, m3u_archive_epg_reference_ts};
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
#[test]
|
||||
fn archive_epg_reference_supports_query_and_path_formats() {
|
||||
@@ -740,4 +762,15 @@ mod tests {
|
||||
fn archive_epg_reference_rejects_plain_start_queries() {
|
||||
assert_eq!(m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?start=1700000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hls_provider_session_headers_converts_set_cookie_to_cookie_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("set-cookie", "sid=abc; Path=/; HttpOnly".parse().expect("valid cookie"));
|
||||
headers.append("set-cookie", "pref=1; Secure".parse().expect("valid cookie"));
|
||||
|
||||
let session_headers = extract_hls_provider_session_headers(&headers);
|
||||
|
||||
assert_eq!(session_headers.get("cookie").map(String::as_str), Some("sid=abc; pref=1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::{api::{
|
||||
library_scan::{spawn_library_scan, LibraryScanTaskOptions},
|
||||
model::{AppState, EventMessage},
|
||||
model::AppState,
|
||||
}, auth::permission_layer, library::{resolve_metadata_storage_path, LibraryProcessor, MetadataStorage}};
|
||||
use axum::response::IntoResponse;
|
||||
use log::{debug, warn};
|
||||
use serde_json::json;
|
||||
use shared::model::{permission::Permission, LibraryScanRequest, LibraryScanSummary, LibraryScanSummaryStatus, LibraryStatus};
|
||||
use shared::model::{
|
||||
permission::Permission, LibraryScanRequest, LibraryStatus, OperationRunAccepted,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Triggers a library scan
|
||||
@@ -17,12 +19,6 @@ async fn scan_library(
|
||||
|
||||
let Some(permit) = app_state.update_guard.try_library() else {
|
||||
warn!("Library update already in progress; update skipped.");
|
||||
let response = LibraryScanSummary {
|
||||
status: LibraryScanSummaryStatus::Error,
|
||||
message: "Library update already in progress.".to_string(),
|
||||
result: None,
|
||||
};
|
||||
let _ = app_state.event_manager.send_event(EventMessage::LibraryScanProgress(response));
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({"error": "Library update already in progress.".to_string()})),
|
||||
@@ -36,12 +32,6 @@ async fn scan_library(
|
||||
match config.library.as_ref() {
|
||||
Some(lib) if lib.enabled => (lib.clone(), config.metadata_update.clone(), config.storage_dir.clone()),
|
||||
_ => {
|
||||
let response = LibraryScanSummary {
|
||||
status: LibraryScanSummaryStatus::Error,
|
||||
message: "Library is not enabled".to_string(),
|
||||
result: None,
|
||||
};
|
||||
let _ = app_state.event_manager.send_event(EventMessage::LibraryScanProgress(response));
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({"error": "Library is not enabled".to_string()})),
|
||||
@@ -61,7 +51,7 @@ async fn scan_library(
|
||||
permit,
|
||||
);
|
||||
|
||||
axum::http::StatusCode::ACCEPTED.into_response()
|
||||
(axum::http::StatusCode::ACCEPTED, axum::Json(OperationRunAccepted {})).into_response()
|
||||
}
|
||||
|
||||
/// Gets Library status
|
||||
@@ -184,4 +174,4 @@ pub fn library_api_register(
|
||||
.route("/library/status", axum::routing::get(get_library_status))
|
||||
.route("/library/thumbnail/{uuid}", axum::routing::get(get_thumbnail)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde_json::json;
|
||||
use shared::utils::deobfuscate_text;
|
||||
use shared::{
|
||||
model::{
|
||||
permission::Permission, EpgChannel,
|
||||
permission::Permission, EpgChannel, OperationRunAccepted,
|
||||
InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem,
|
||||
XtreamCluster,
|
||||
},
|
||||
@@ -213,11 +213,14 @@ async fn playlist_update(
|
||||
// Deduplicate rapid clicks: the channel has capacity 1, so at most one
|
||||
// update is queued at any time. Additional requests while the channel
|
||||
// is full are silently dropped — the pending run already covers them.
|
||||
match app_state.manual_update_sender.try_send(valid_targets) {
|
||||
Ok(()) => axum::http::StatusCode::ACCEPTED.into_response(),
|
||||
match app_state
|
||||
.manual_update_sender
|
||||
.try_send(crate::api::model::ManualPlaylistUpdateRequest { targets: valid_targets })
|
||||
{
|
||||
Ok(()) => (axum::http::StatusCode::ACCEPTED, axum::Json(OperationRunAccepted {})).into_response(),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
debug!("Manual playlist update deduplicated: an update is already pending or running");
|
||||
axum::http::StatusCode::ACCEPTED.into_response()
|
||||
(axum::http::StatusCode::ACCEPTED, axum::Json(OperationRunAccepted {})).into_response()
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
debug!("Manual playlist update rejected: worker channel closed (server shutting down)");
|
||||
@@ -765,7 +768,7 @@ mod tests {
|
||||
downloads: CancellationToken::new(),
|
||||
};
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<crate::model::ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(crate::model::ProcessTargets {
|
||||
|
||||
@@ -110,6 +110,10 @@ fn websocket_can_receive_runtime_events(mem: &ProtocolHandlerMemory, event: &Eve
|
||||
EventMessage::DownloadsUpdate(_) | EventMessage::DownloadsDeltaUpdate(_) => {
|
||||
mem.permissions.contains(Permission::DownloadRead)
|
||||
}
|
||||
EventMessage::PlaylistUpdateProgress(_) | EventMessage::PlaylistUpdate(_) => {
|
||||
mem.permissions.contains(Permission::PlaylistWrite)
|
||||
}
|
||||
EventMessage::LibraryScanProgress(_) => mem.permissions.contains(Permission::LibraryWrite),
|
||||
_ => mem.permissions.contains(Permission::SystemRead),
|
||||
}
|
||||
}
|
||||
@@ -364,10 +368,10 @@ async fn handle_event_message(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EventMessage::PlaylistUpdateProgress(target, msg) => {
|
||||
EventMessage::PlaylistUpdateProgress(progress) => {
|
||||
send_event_response(
|
||||
socket,
|
||||
ProtocolMessage::PlaylistUpdateProgressResponse(target, msg),
|
||||
ProtocolMessage::PlaylistUpdateProgressResponse(progress),
|
||||
"Playlist update progress event",
|
||||
)
|
||||
.await?;
|
||||
@@ -380,10 +384,10 @@ async fn handle_event_message(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EventMessage::LibraryScanProgress(summary) => {
|
||||
EventMessage::LibraryScanProgress(progress) => {
|
||||
send_event_response(
|
||||
socket,
|
||||
ProtocolMessage::LibraryScanProgressResponse(summary),
|
||||
ProtocolMessage::LibraryScanProgressResponse(progress),
|
||||
"Library scan progress event",
|
||||
)
|
||||
.await?;
|
||||
@@ -511,7 +515,11 @@ async fn handle_user_action(app_state: &Arc<AppState>, cmd: UserCommand) -> bool
|
||||
mod tests {
|
||||
use super::websocket_can_receive_runtime_events;
|
||||
use crate::api::model::EventMessage;
|
||||
use shared::model::{DownloadsDelta, DownloadsResponse, FileDownloadDto, Permission, ProtocolHandlerMemory, TaskKindDto, TaskPriorityDto, TransferStatusDto, UserRole};
|
||||
use shared::model::{
|
||||
DownloadsDelta, DownloadsResponse, FileDownloadDto, LibraryScanProgressEvent, LibraryScanSummary,
|
||||
LibraryScanSummaryStatus, Permission, PlaylistUpdateProgressEvent, ProtocolHandlerMemory, TaskKindDto,
|
||||
TaskPriorityDto, TransferStatusDto, UserRole,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_websocket_runtime_events_allowed_for_admin() {
|
||||
@@ -568,6 +576,43 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_playlist_progress_allowed_for_playlist_write_without_system_read() {
|
||||
let mut mem = ProtocolHandlerMemory {
|
||||
permissions: Permission::PlaylistWrite.into(),
|
||||
..ProtocolHandlerMemory::default()
|
||||
};
|
||||
mem.role = UserRole::User;
|
||||
|
||||
assert!(websocket_can_receive_runtime_events(
|
||||
&mem,
|
||||
&EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent {
|
||||
target: "target".to_string(),
|
||||
message: "step".to_string(),
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_library_progress_allowed_for_library_write_without_system_read() {
|
||||
let mut mem = ProtocolHandlerMemory {
|
||||
permissions: Permission::LibraryWrite.into(),
|
||||
..ProtocolHandlerMemory::default()
|
||||
};
|
||||
mem.role = UserRole::User;
|
||||
|
||||
assert!(websocket_can_receive_runtime_events(
|
||||
&mem,
|
||||
&EventMessage::LibraryScanProgress(LibraryScanProgressEvent {
|
||||
summary: LibraryScanSummary {
|
||||
status: LibraryScanSummaryStatus::Success,
|
||||
message: "done".to_string(),
|
||||
result: None,
|
||||
},
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_download_updates_allowed_for_download_read_user() {
|
||||
let mut mem = ProtocolHandlerMemory {
|
||||
|
||||
@@ -677,9 +677,7 @@ pub(crate) async fn stream_epg_api(
|
||||
return stream_epg_bad_request(&format!("unknown target_id: {target_id}"));
|
||||
};
|
||||
|
||||
let epg_path = get_epg_path_for_target_by_type(config.as_ref(), &target, TargetType::M3u)
|
||||
.or_else(|| get_epg_path_for_target_by_type(config.as_ref(), &target, TargetType::Xtream));
|
||||
let Some(epg_path) = epg_path else {
|
||||
let Some(epg_path) = get_epg_path_for_target(config.as_ref(), &target) else {
|
||||
entries.extend(empty_stream_epg_entries(target_id, &items));
|
||||
continue;
|
||||
};
|
||||
@@ -842,6 +840,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_target_with_xtream_only() -> ConfigTarget {
|
||||
ConfigTarget {
|
||||
id: 1,
|
||||
enabled: true,
|
||||
name: "xtream-only".to_string(),
|
||||
options: None,
|
||||
sort: None,
|
||||
filter: Filter::default(),
|
||||
output: vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
rename: None,
|
||||
mapping_ids: None,
|
||||
mapping: Arc::new(ArcSwapOption::new(None)),
|
||||
favourites: None,
|
||||
processing_order: ProcessingOrder::default(),
|
||||
watch: None,
|
||||
use_memory_cache: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config_with_storage(storage_dir: &str) -> Config {
|
||||
Config {
|
||||
storage_dir: storage_dir.to_string(),
|
||||
@@ -892,6 +913,26 @@ mod tests {
|
||||
assert_eq!(picked, xtream_epg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_epg_path_for_target_xtream_only_target_uses_xtream_path() {
|
||||
// Regression: stream_epg_api used to probe m3u first regardless of the target's
|
||||
// configured outputs, producing a misleading "Can't find epg file" TRACE for
|
||||
// xtream-only targets. After the fix, get_epg_path_for_target iterates
|
||||
// target.output and must not look for an m3u epg at all.
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let config = test_config_with_storage(dir.path().to_string_lossy().as_ref());
|
||||
let target = test_target_with_xtream_only();
|
||||
|
||||
let xtream_storage = crate::repository::xtream_get_storage_path(&config, &target.name).expect("xtream storage");
|
||||
let xtream_epg = crate::repository::xtream_get_epg_file_path_for_target(&xtream_storage);
|
||||
|
||||
fs::create_dir_all(&xtream_storage).expect("create xtream dir");
|
||||
fs::write(&xtream_epg, b"xtream").expect("write xtream epg");
|
||||
|
||||
let picked = get_epg_path_for_target(&config, &target).expect("xtream path");
|
||||
assert_eq!(picked, xtream_epg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_stream_epg_items_rejects_empty_requests() {
|
||||
let err = group_stream_epg_items(Vec::new()).unwrap_err();
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
model::{LibraryConfig, MetadataUpdateConfig},
|
||||
};
|
||||
use log::{error, info};
|
||||
use shared::model::{LibraryScanSummary, LibraryScanSummaryStatus};
|
||||
use shared::model::{LibraryScanProgressEvent, LibraryScanSummary, LibraryScanSummaryStatus};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) struct LibraryScanTaskOptions {
|
||||
@@ -37,7 +37,8 @@ pub(crate) fn spawn_library_scan(
|
||||
),
|
||||
result: Some(result),
|
||||
};
|
||||
let _ = event_manager.send_event(EventMessage::LibraryScanProgress(response));
|
||||
let _ = event_manager
|
||||
.send_event(EventMessage::LibraryScanProgress(LibraryScanProgressEvent { summary: response }));
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{prefix}Library scan failed: {err}");
|
||||
@@ -46,7 +47,8 @@ pub(crate) fn spawn_library_scan(
|
||||
message: format!("{prefix}Scan failed: {err}"),
|
||||
result: None,
|
||||
};
|
||||
let _ = event_manager.send_event(EventMessage::LibraryScanProgress(response));
|
||||
let _ = event_manager
|
||||
.send_event(EventMessage::LibraryScanProgress(LibraryScanProgressEvent { summary: response }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
model::{
|
||||
create_cache, create_http_client, create_http_client_no_redirect, exec_provider_dns,
|
||||
ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue,
|
||||
EventManager, EventMessage, HdHomerunAppState, MetadataUpdateManager, PlaylistStorageState,
|
||||
EventManager, EventMessage, HdHomerunAppState, ManualPlaylistUpdateRequest, MetadataUpdateManager, PlaylistStorageState,
|
||||
SharedStreamManager, UpdateGuard, exec_qos_aggregation,
|
||||
},
|
||||
panel_api::sync_panel_api_exp_dates_on_boot,
|
||||
@@ -258,7 +258,7 @@ async fn healthcheck() -> impl axum::response::IntoResponse { axum::Json(create_
|
||||
async fn create_shared_data(
|
||||
app_config: &Arc<AppConfig>,
|
||||
forced_targets: &Arc<ProcessTargets>,
|
||||
) -> Result<(AppState, mpsc::Receiver<Arc<ProcessTargets>>), TuliproxError> {
|
||||
) -> Result<(AppState, mpsc::Receiver<ManualPlaylistUpdateRequest>), TuliproxError> {
|
||||
let config = app_config.config.load();
|
||||
let downloads_state_file = std::path::PathBuf::from(&config.storage_dir).join("downloads_state.json");
|
||||
|
||||
@@ -304,7 +304,7 @@ async fn create_shared_data(
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let cancel_tokens = Arc::new(ArcSwap::from_pointee(tokens));
|
||||
|
||||
let (manual_update_sender, manual_update_rx) = mpsc::channel::<Arc<ProcessTargets>>(1);
|
||||
let (manual_update_sender, manual_update_rx) = mpsc::channel::<ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
let app_state = AppState {
|
||||
forced_targets: Arc::new(ArcSwap::new(Arc::clone(forced_targets))),
|
||||
@@ -334,13 +334,13 @@ async fn create_shared_data(
|
||||
async fn run_manual_update_worker(
|
||||
client: reqwest::Client,
|
||||
app_state: Arc<AppState>,
|
||||
mut rx: mpsc::Receiver<Arc<ProcessTargets>>,
|
||||
mut rx: mpsc::Receiver<ManualPlaylistUpdateRequest>,
|
||||
) {
|
||||
while let Some(targets) = rx.recv().await {
|
||||
while let Some(request) = rx.recv().await {
|
||||
exec_processing(
|
||||
&client,
|
||||
Arc::clone(&app_state.app_config),
|
||||
targets,
|
||||
request.targets,
|
||||
Some(Arc::clone(&app_state.event_manager)),
|
||||
Some(Arc::clone(&app_state)),
|
||||
Some(Arc::clone(&app_state.playlists)),
|
||||
|
||||
@@ -128,6 +128,7 @@ pub struct UserSession {
|
||||
pub virtual_id: u32,
|
||||
pub provider: Arc<str>,
|
||||
pub stream_url: Arc<str>,
|
||||
pub provider_session_headers: HashMap<String, String>,
|
||||
pub addr: SocketAddr,
|
||||
pub socket_bound: bool,
|
||||
pub active_addrs: Vec<SocketAddr>,
|
||||
@@ -662,6 +663,14 @@ impl ActiveUserManager {
|
||||
key
|
||||
}
|
||||
|
||||
fn admission_gate_key(username: &str) -> String {
|
||||
let mut key = String::with_capacity(username.len() + 11);
|
||||
key.push_str("admission");
|
||||
key.push('\0');
|
||||
key.push_str(username);
|
||||
key
|
||||
}
|
||||
|
||||
fn cleanup_idle_transition_gates(transition_gates: &mut HashMap<String, Arc<Mutex<()>>>) {
|
||||
transition_gates.retain(|_, gate| Arc::strong_count(gate) > 1);
|
||||
}
|
||||
@@ -680,6 +689,20 @@ impl ActiveUserManager {
|
||||
gate.lock_owned().await
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_user_admission(&self, username: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
let key = Self::admission_gate_key(username);
|
||||
let gate = {
|
||||
let mut transition_gates = self.transition_gates.lock().await;
|
||||
Self::cleanup_idle_transition_gates(&mut transition_gates);
|
||||
Arc::clone(
|
||||
transition_gates
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
};
|
||||
gate.lock_owned().await
|
||||
}
|
||||
|
||||
fn should_reuse_stream_for_session(existing_stream: &StreamInfo, incoming_channel: &StreamChannel) -> bool {
|
||||
existing_stream.channel.item_type.requires_provider_affinity() || incoming_channel.item_type.requires_provider_affinity()
|
||||
}
|
||||
@@ -1586,6 +1609,7 @@ impl ActiveUserManager {
|
||||
virtual_id: params.virtual_id,
|
||||
provider: params.provider.intern(),
|
||||
stream_url: params.stream_url.intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr: *params.addr,
|
||||
socket_bound: params.socket_bound,
|
||||
active_addrs: vec![*params.addr],
|
||||
@@ -1961,11 +1985,17 @@ impl ActiveUserManager {
|
||||
session.socket_bound = socket_bound;
|
||||
remember_session_addr(session, *addr);
|
||||
Self::bump_session_transition_version(session);
|
||||
let mut reset_provider_session_headers = false;
|
||||
if &*session.stream_url != stream_url {
|
||||
session.stream_url = stream_url.intern();
|
||||
reset_provider_session_headers = true;
|
||||
}
|
||||
if &*session.provider != provider {
|
||||
session.provider = provider.intern();
|
||||
reset_provider_session_headers = true;
|
||||
}
|
||||
if reset_provider_session_headers {
|
||||
session.provider_session_headers.clear();
|
||||
}
|
||||
// Normalize stale lifecycle states on session refresh.
|
||||
// Expired, PendingProvider, and Preserved sessions cannot stay in those states
|
||||
@@ -2673,6 +2703,24 @@ impl ActiveUserManager {
|
||||
self.update_user_session(username, token).await
|
||||
}
|
||||
|
||||
pub async fn update_session_provider_headers(
|
||||
&self,
|
||||
username: &str,
|
||||
token: &str,
|
||||
provider_session_headers: &HashMap<String, String>,
|
||||
) -> bool {
|
||||
let mut user_connections = self.connections.write().await;
|
||||
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
||||
return false;
|
||||
};
|
||||
let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) else {
|
||||
return false;
|
||||
};
|
||||
session.provider_session_headers.clone_from(provider_session_headers);
|
||||
session.ts = current_time_secs();
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn pending_provider_version(&self, username: &str, token: &str) -> Option<u64> {
|
||||
let user_connections = self.connections.read().await;
|
||||
let connection_data = user_connections.by_key.get(username)?;
|
||||
@@ -3258,7 +3306,7 @@ mod tests {
|
||||
model::{PlaylistItemType, StreamChannel, StreamInfo, XtreamCluster},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::{borrow::Cow, sync::Arc};
|
||||
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||
use shared::model::ProxyType;
|
||||
|
||||
fn test_channel(virtual_id: u32) -> StreamChannel {
|
||||
@@ -3326,6 +3374,7 @@ mod tests {
|
||||
virtual_id: 7001,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/live.m3u8".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![addr],
|
||||
@@ -3387,6 +3436,7 @@ mod tests {
|
||||
virtual_id: 7002,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/live.m3u8".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![addr],
|
||||
@@ -3430,6 +3480,134 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_session_provider_headers_updates_existing_session_and_timestamp() {
|
||||
let config = Config::default();
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let event_manager = Arc::new(EventManager::new());
|
||||
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:55402".parse().unwrap_or_else(|_| unreachable!());
|
||||
let mut user = ProxyUserCredentials::default();
|
||||
user.username = "user-provider-headers".to_string();
|
||||
|
||||
manager
|
||||
.create_user_session(CreateUserSessionParams {
|
||||
user: &user,
|
||||
session_token: "tok-provider-headers",
|
||||
virtual_id: 7003,
|
||||
provider: "provider-a",
|
||||
stream_url: "http://localhost/live.m3u8",
|
||||
addr: &addr,
|
||||
connection_permission: UserConnectionPermission::Allowed,
|
||||
connection_kind: Some(ConnectionKind::Normal),
|
||||
socket_bound: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
let before = manager
|
||||
.get_and_update_user_session(&user.username, "tok-provider-headers")
|
||||
.await
|
||||
.expect("session should exist");
|
||||
let previous_ts = before.ts;
|
||||
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
||||
|
||||
assert!(manager
|
||||
.update_session_provider_headers(&user.username, "tok-provider-headers", &headers)
|
||||
.await);
|
||||
|
||||
let after = manager
|
||||
.get_and_update_user_session(&user.username, "tok-provider-headers")
|
||||
.await
|
||||
.expect("session should exist");
|
||||
assert_eq!(after.provider_session_headers, headers);
|
||||
assert!(after.ts >= previous_ts);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_session_provider_headers_returns_false_for_missing_user_or_token() {
|
||||
let config = Config::default();
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let event_manager = Arc::new(EventManager::new());
|
||||
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
||||
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
||||
|
||||
assert!(!manager
|
||||
.update_session_provider_headers("missing-user", "missing-token", &headers)
|
||||
.await);
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:55403".parse().unwrap_or_else(|_| unreachable!());
|
||||
let mut user = ProxyUserCredentials::default();
|
||||
user.username = "user-missing-token".to_string();
|
||||
manager
|
||||
.create_user_session(CreateUserSessionParams {
|
||||
user: &user,
|
||||
session_token: "tok-existing",
|
||||
virtual_id: 7004,
|
||||
provider: "provider-a",
|
||||
stream_url: "http://localhost/live.m3u8",
|
||||
addr: &addr,
|
||||
connection_permission: UserConnectionPermission::Allowed,
|
||||
connection_kind: Some(ConnectionKind::Normal),
|
||||
socket_bound: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(!manager
|
||||
.update_session_provider_headers(&user.username, "tok-missing", &headers)
|
||||
.await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_user_session_clears_provider_headers_when_provider_or_stream_url_changes() {
|
||||
let config = Config::default();
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let event_manager = Arc::new(EventManager::new());
|
||||
let manager = ActiveUserManager::new(&config, &geoip, &event_manager);
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:55404".parse().unwrap_or_else(|_| unreachable!());
|
||||
let mut user = ProxyUserCredentials::default();
|
||||
user.username = "user-provider-header-reset".to_string();
|
||||
let headers = HashMap::from([(String::from("cookie"), String::from("sid=abc"))]);
|
||||
|
||||
manager
|
||||
.create_user_session(CreateUserSessionParams {
|
||||
user: &user,
|
||||
session_token: "tok-reset",
|
||||
virtual_id: 7005,
|
||||
provider: "provider-a",
|
||||
stream_url: "http://localhost/live-a.m3u8",
|
||||
addr: &addr,
|
||||
connection_permission: UserConnectionPermission::Allowed,
|
||||
connection_kind: Some(ConnectionKind::Normal),
|
||||
socket_bound: false,
|
||||
})
|
||||
.await;
|
||||
assert!(manager
|
||||
.update_session_provider_headers(&user.username, "tok-reset", &headers)
|
||||
.await);
|
||||
|
||||
manager
|
||||
.create_user_session(CreateUserSessionParams {
|
||||
user: &user,
|
||||
session_token: "tok-reset",
|
||||
virtual_id: 7005,
|
||||
provider: "provider-b",
|
||||
stream_url: "http://localhost/live-b.m3u8",
|
||||
addr: &addr,
|
||||
connection_permission: UserConnectionPermission::Allowed,
|
||||
connection_kind: Some(ConnectionKind::Normal),
|
||||
socket_bound: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
let session = manager
|
||||
.get_and_update_user_session(&user.username, "tok-reset")
|
||||
.await
|
||||
.expect("session should exist");
|
||||
assert!(session.provider_session_headers.is_empty());
|
||||
}
|
||||
|
||||
/// `terminate_session` expires a session and removes it.
|
||||
#[tokio::test]
|
||||
async fn terminate_session_expires_and_removes_session() {
|
||||
@@ -7787,6 +7965,7 @@ mod tests {
|
||||
virtual_id: 9002,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/stream.ts".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![addr],
|
||||
@@ -7828,6 +8007,7 @@ mod tests {
|
||||
virtual_id: 9003,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/stream.ts".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![addr],
|
||||
@@ -7902,6 +8082,7 @@ mod tests {
|
||||
virtual_id: 9004,
|
||||
provider: "provider-a".intern(),
|
||||
stream_url: "http://localhost/stream.ts".intern(),
|
||||
provider_session_headers: HashMap::new(),
|
||||
addr,
|
||||
socket_bound: false,
|
||||
active_addrs: vec![addr],
|
||||
|
||||
@@ -390,6 +390,11 @@ fn video_download_changed(a: &crate::model::VideoDownloadConfig, b: &crate::mode
|
||||
VideoDownloadConfigDto::from(a) != VideoDownloadConfigDto::from(b)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ManualPlaylistUpdateRequest {
|
||||
pub targets: Arc<ProcessTargets>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub forced_targets: Arc<ArcSwap<ProcessTargets>>, // as program arguments
|
||||
@@ -412,7 +417,7 @@ pub struct AppState {
|
||||
/// `try_send` deduplicates rapid clicks: if an update is already pending
|
||||
/// or the channel is full, the request is silently dropped so at most one
|
||||
/// update is queued at any time regardless of how many times the button is clicked.
|
||||
pub manual_update_sender: mpsc::Sender<Arc<ProcessTargets>>,
|
||||
pub manual_update_sender: mpsc::Sender<ManualPlaylistUpdateRequest>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use crate::api::model::streams::{MeterReading, StreamMeterHandle};
|
||||
use log::trace;
|
||||
use shared::model::{
|
||||
ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse, LibraryScanSummary, PlaylistUpdateState,
|
||||
StreamMeterEntry, SystemInfo,
|
||||
ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse, LibraryScanProgressEvent,
|
||||
PlaylistUpdateProgressEvent, PlaylistUpdateState, StreamMeterEntry, SystemInfo,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
sync::{atomic::AtomicUsize, atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -26,9 +23,9 @@ pub enum EventMessage {
|
||||
ActiveProvider(Arc<str>, usize),
|
||||
ConfigChange(ConfigType),
|
||||
PlaylistUpdate(PlaylistUpdateState),
|
||||
PlaylistUpdateProgress(String, String),
|
||||
PlaylistUpdateProgress(PlaylistUpdateProgressEvent),
|
||||
SystemInfoUpdate(SystemInfo),
|
||||
LibraryScanProgress(LibraryScanSummary),
|
||||
LibraryScanProgress(LibraryScanProgressEvent),
|
||||
DownloadsUpdate(DownloadsResponse),
|
||||
DownloadsDeltaUpdate(DownloadsDelta),
|
||||
InputMetadataUpdatesCompleted(Arc<str>),
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct StreamDetails {
|
||||
pub(crate) stream_info: ProviderStreamInfo,
|
||||
pub provider_name: Option<Arc<str>>,
|
||||
pub request_url: Option<Arc<str>>,
|
||||
pub session_headers: Option<HashMap<String, String>>,
|
||||
pub grace_period: GracePeriodOptions,
|
||||
pub provider_grace_active: bool,
|
||||
pub disable_provider_grace: bool,
|
||||
@@ -51,6 +52,7 @@ impl Clone for StreamDetails {
|
||||
stream_info: self.stream_info.clone(),
|
||||
provider_name: self.provider_name.clone(),
|
||||
request_url: self.request_url.clone(),
|
||||
session_headers: self.session_headers.clone(),
|
||||
grace_period: self.grace_period,
|
||||
provider_grace_active: self.provider_grace_active,
|
||||
disable_provider_grace: self.disable_provider_grace,
|
||||
@@ -68,6 +70,7 @@ impl StreamDetails {
|
||||
stream_info: None,
|
||||
provider_name: None,
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: grace_period_options,
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: false,
|
||||
|
||||
@@ -462,6 +462,7 @@ fn create_deferred_provider_open_future(
|
||||
stream_url: &stream_url,
|
||||
req_headers,
|
||||
input_headers: Some(&input.headers),
|
||||
session_headers: stream_details.session_headers.as_ref(),
|
||||
disabled_headers: disabled_headers.as_ref(),
|
||||
default_user_agent: default_user_agent.as_deref(),
|
||||
username: None,
|
||||
@@ -1370,7 +1371,7 @@ mod tests {
|
||||
|
||||
let tokens = CancelTokens::default();
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets {
|
||||
@@ -1437,7 +1438,7 @@ mod tests {
|
||||
|
||||
let tokens = CancelTokens::default();
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
let (manual_update_sender, _) = mpsc::channel::<Arc<ProcessTargets>>(1);
|
||||
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
|
||||
|
||||
Arc::new(AppState {
|
||||
forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets {
|
||||
@@ -1511,6 +1512,7 @@ mod tests {
|
||||
stream_info: None,
|
||||
provider_name: Some(Arc::clone(provider_name)),
|
||||
request_url: Some("http://provider-1.example/live/1".intern()),
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions {
|
||||
period_millis: 100,
|
||||
timeout_secs: 0,
|
||||
@@ -2361,6 +2363,7 @@ mod tests {
|
||||
stream_info: None,
|
||||
provider_name: Some(provider_name),
|
||||
request_url: Some("http://provider-1.example/live/2.ts".intern()),
|
||||
session_headers: None,
|
||||
grace_period: GracePeriodOptions {
|
||||
period_millis: 100,
|
||||
timeout_secs: 0,
|
||||
|
||||
@@ -56,7 +56,7 @@ create_bitset!(
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderStreamFactoryOptions {
|
||||
addr: SocketAddr,
|
||||
// item_type: PlaylistItemType,
|
||||
item_type: PlaylistItemType,
|
||||
flags: ProviderStreamFactoryFlagsSet,
|
||||
buffer_size: usize,
|
||||
url: Url,
|
||||
@@ -80,6 +80,7 @@ pub(crate) struct ProviderStreamFactoryParams<'a> {
|
||||
pub stream_url: &'a Url,
|
||||
pub req_headers: &'a HeaderMap,
|
||||
pub input_headers: Option<&'a HashMap<String, String>>,
|
||||
pub session_headers: Option<&'a HashMap<String, String>>,
|
||||
pub disabled_headers: Option<&'a ReverseProxyDisabledHeaderConfig>,
|
||||
pub default_user_agent: Option<&'a str>,
|
||||
pub username: Option<&'a str>,
|
||||
@@ -98,6 +99,7 @@ impl ProviderStreamFactoryOptions {
|
||||
stream_url,
|
||||
req_headers,
|
||||
input_headers,
|
||||
session_headers,
|
||||
disabled_headers,
|
||||
default_user_agent,
|
||||
username,
|
||||
@@ -115,8 +117,15 @@ impl ProviderStreamFactoryOptions {
|
||||
let requested_range = get_request_range_start_bytes(&req_headers);
|
||||
req_headers.remove("range");
|
||||
|
||||
let merged_input_headers = merge_provider_request_headers(*input_headers, *session_headers);
|
||||
|
||||
// We merge configured input headers with the headers from the request.
|
||||
let headers = get_request_headers(*input_headers, Some(&req_headers), *disabled_headers, *default_user_agent);
|
||||
let headers = get_request_headers(
|
||||
merged_input_headers.as_ref(),
|
||||
Some(&req_headers),
|
||||
*disabled_headers,
|
||||
*default_user_agent,
|
||||
);
|
||||
|
||||
let default_user_agent = default_user_agent
|
||||
.and_then(|ua| {
|
||||
@@ -151,7 +160,7 @@ impl ProviderStreamFactoryOptions {
|
||||
}
|
||||
|
||||
Self {
|
||||
// item_type,
|
||||
item_type: *item_type,
|
||||
addr: *addr,
|
||||
flags,
|
||||
buffer_size,
|
||||
@@ -194,6 +203,9 @@ impl ProviderStreamFactoryOptions {
|
||||
#[inline]
|
||||
pub fn get_url_as_str(&self) -> &str { self.url.as_str() }
|
||||
|
||||
#[inline]
|
||||
fn get_item_type(&self) -> PlaylistItemType { self.item_type }
|
||||
|
||||
#[inline]
|
||||
pub fn should_retry_provider_request(&self) -> bool { self.flags.contains(ProviderStreamFactoryFlags::RetryEnabled) }
|
||||
|
||||
@@ -244,6 +256,23 @@ impl ProviderStreamFactoryOptions {
|
||||
|
||||
}
|
||||
|
||||
fn merge_provider_request_headers(
|
||||
input_headers: Option<&HashMap<String, String>>,
|
||||
session_headers: Option<&HashMap<String, String>>,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
match (input_headers, session_headers) {
|
||||
(None, None) => None,
|
||||
(Some(headers), None) | (None, Some(headers)) => Some(headers.clone()),
|
||||
(Some(input), Some(session)) => {
|
||||
let mut merged = input.clone();
|
||||
for (key, value) in session {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
Some(merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_provider_open_failure(
|
||||
app_state: &Arc<AppState>,
|
||||
stream_options: &ProviderStreamFactoryOptions,
|
||||
@@ -286,6 +315,17 @@ fn classify_provider_status_error(status: StatusCode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_content_type_looks_like_html(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.split(';').next().unwrap_or_default().trim().eq_ignore_ascii_case("text/html"))
|
||||
}
|
||||
|
||||
fn should_reject_success_response_content_type(item_type: PlaylistItemType, headers: &HeaderMap) -> bool {
|
||||
!item_type.is_live_adaptive() && provider_content_type_looks_like_html(headers)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ProviderStreamRequestFailure {
|
||||
Status {
|
||||
@@ -588,6 +628,17 @@ async fn provider_stream_request(
|
||||
debug!("{}", sanitize_sensitive_info(&message));
|
||||
}
|
||||
if status.is_success() {
|
||||
if should_reject_success_response_content_type(stream_options.get_item_type(), response.headers()) {
|
||||
debug!(
|
||||
"Provider returned HTML content for non-adaptive stream {}",
|
||||
sanitize_sensitive_info(stream_options.get_log_url().as_ref())
|
||||
);
|
||||
return Err(ProviderStreamRequestFailure::Status {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
provider_error_class: "unexpected_content_type",
|
||||
serve_channel_unavailable: true,
|
||||
});
|
||||
}
|
||||
let response_info = {
|
||||
// Unfortunately, the HEAD request does not work, so we need this workaround.
|
||||
// We need some header information from the provider, we extract the necessary headers and forward them to the client
|
||||
@@ -846,6 +897,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -866,6 +918,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -886,6 +939,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -907,6 +961,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -934,6 +989,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers: None,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -950,6 +1006,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers: None,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -980,6 +1037,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers: None,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
@@ -1010,6 +1068,7 @@ mod tests {
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: None,
|
||||
disabled_headers: None,
|
||||
default_user_agent: None,
|
||||
username: Some("alice"),
|
||||
@@ -1042,4 +1101,56 @@ mod tests {
|
||||
assert_eq!(info.channel.input_name.as_ref(), "input-a");
|
||||
assert_eq!(info.channel.virtual_id, 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_content_type_is_rejected_for_catchup_streams() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(reqwest::header::CONTENT_TYPE, "text/html; charset=UTF-8".parse().unwrap());
|
||||
|
||||
assert!(should_reject_success_response_content_type(PlaylistItemType::Catchup, &headers));
|
||||
assert!(should_reject_success_response_content_type(PlaylistItemType::Video, &headers));
|
||||
assert!(should_reject_success_response_content_type(PlaylistItemType::Live, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_content_type_is_allowed_for_live_adaptive_streams() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(reqwest::header::CONTENT_TYPE, "text/html; charset=UTF-8".parse().unwrap());
|
||||
|
||||
assert!(!should_reject_success_response_content_type(PlaylistItemType::LiveHls, &headers));
|
||||
assert!(!should_reject_success_response_content_type(PlaylistItemType::LiveDash, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_headers_are_forwarded_to_provider_requests() {
|
||||
let addr = "127.0.0.1:8080".parse().unwrap();
|
||||
let stream_url = Url::parse("http://example.com/live/segment.ts").unwrap();
|
||||
let req_headers = HeaderMap::new();
|
||||
let mut session_headers = HashMap::new();
|
||||
session_headers.insert(String::from("cookie"), String::from("sid=abc; pref=1"));
|
||||
let stream_options =
|
||||
StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false };
|
||||
|
||||
let options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams {
|
||||
addr,
|
||||
item_type: PlaylistItemType::LiveHls,
|
||||
share_stream: false,
|
||||
stream_options: &stream_options,
|
||||
stream_url: &stream_url,
|
||||
req_headers: &req_headers,
|
||||
input_headers: None,
|
||||
session_headers: Some(&session_headers),
|
||||
disabled_headers: None,
|
||||
default_user_agent: None,
|
||||
username: None,
|
||||
client_ip: None,
|
||||
stream_channel: None,
|
||||
connect_failure_stage: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
options.get_headers().get(axum::http::header::COOKIE).and_then(|value| value.to_str().ok()),
|
||||
Some("sid=abc; pref=1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,12 +696,9 @@ impl SharedStreamManager {
|
||||
self.get_shared_state(stream_url).await.map(|s| s.headers.clone())
|
||||
}
|
||||
|
||||
pub async fn register_meter_uid(&self, stream_url: &str, meter_uid: u32) {
|
||||
self.meter_uids.write().await.insert(stream_url.to_string(), meter_uid);
|
||||
}
|
||||
|
||||
pub async fn get_meter_uid(&self, stream_url: &str) -> Option<u32> {
|
||||
self.meter_uids.read().await.get(stream_url).copied()
|
||||
pub async fn get_or_register_meter_uid(&self, stream_url: &str, uid_factory: impl FnOnce() -> u32) -> u32 {
|
||||
let mut uids = self.meter_uids.write().await;
|
||||
*uids.entry(stream_url.to_string()).or_insert_with(uid_factory)
|
||||
}
|
||||
|
||||
async fn forget_subscriber_addr(&self, addr: &SocketAddr) -> Option<Arc<str>> {
|
||||
|
||||
@@ -3754,6 +3754,7 @@ pub fn create_panel_api_provisioning_stream_details(
|
||||
stream_info,
|
||||
provider_name,
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: *grace_period_options,
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: true,
|
||||
@@ -3779,6 +3780,7 @@ pub fn create_panel_api_provisioning_stream_details(
|
||||
stream_info,
|
||||
provider_name,
|
||||
request_url: None,
|
||||
session_headers: None,
|
||||
grace_period: *grace_period_options,
|
||||
provider_grace_active: false,
|
||||
disable_provider_grace: true,
|
||||
|
||||
Reference in New Issue
Block a user