diff --git a/.gitignore b/.gitignore index 7a1303b44..7c343e3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,9 @@ backend/app/Cargo.lock # Scratch directory some backend tests create relative to the crate root. # Test output must never become tracked source. /backend/app/tmp/ + +# Local secret environment files +.env +.env.* +!.env.example + diff --git a/Cargo.lock b/Cargo.lock index 15825e8ed..d18142d8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4427,6 +4427,7 @@ dependencies = [ "rust-argon2", "serde_json", "shared", + "thiserror 2.0.20", "tuliprox-core", "tuliprox-repository", "zeroize", @@ -4446,6 +4447,7 @@ dependencies = [ "serde", "smallvec", "tempfile", + "thiserror 2.0.20", "uuid", ] @@ -4535,6 +4537,7 @@ dependencies = [ "serde_json", "shared", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-util", "tuliprox-auth", @@ -4629,6 +4632,7 @@ dependencies = [ "shared", "strsim", "tempfile", + "thiserror 2.0.20", "tokio", "tuliprox-core", "url", @@ -4650,6 +4654,7 @@ dependencies = [ "serde", "serde_json", "shared", + "thiserror 2.0.20", "tokio", "url", ] @@ -4796,6 +4801,7 @@ dependencies = [ "serde_json", "shared", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 0c45e3e50..c56eec8e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" rust-version = "1.95.0" [workspace.lints.clippy] -# Shared policy for every workspace crate (modularization plan, Phase 0). +# Shared lint policy for every workspace crate. # Extracted crates must not silently lose the lint level that previously # existed only as a crate attribute on backend/app/src/main.rs. all = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index d9624ad11..60d2c241c 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,19 @@ Generate all four formats simultaneously from the same source — one setup, eve - **Environment variables**: `${env:VAR}` interpolation in all config files - **Default User-Agent**: Configurable default user-agent for all outgoing requests +### Configuration & Secrets + +Tuliprox ships with demo placeholder configuration only — nothing in this repository is a real credential. + +- Every config file supports `${env:VAR}` interpolation (`config.yml`, `source.yml`, `api-proxy.yml`, + `mapping.yml`, `template.yml`), so provider logins, webhooks, API keys and tokens can be injected from the + process environment. No particular host or secret store is assumed. +- `config/user.txt` contains sample Argon2 hashes for the demo accounts `test` / `nobody`. Replace them with + hashes generated on your machine via `tuliprox --genpwd`. +- Pin `web_ui.auth.secret` (64-hex) across restarts so logins survive reboots. +- See [Secrets & Environment Variables](docs/src/configuration/secrets.md) for the full guide and the + pre-publish checklist. + ### 20. Digital Video Recorder (DVR) — Record, Manage & Keep What Matters Turn Tuliprox into your personal IPTV recorder. Record live TV directly from your existing sources, automate recurring recordings, diff --git a/backend/app/src/api/api_utils.rs b/backend/app/src/api/api_utils.rs deleted file mode 100644 index bed3dd0bd..000000000 --- a/backend/app/src/api/api_utils.rs +++ /dev/null @@ -1,9492 +0,0 @@ -pub use crate::repository::{ - evaluate_network_access, log_network_access_allowed_geoip_unavailable, log_network_access_denied, - NetworkAccessDecision, NetworkAccessDenyReason, -}; -use crate::{ - api::{ - endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}, - model::{ - create_active_client_stream, create_channel_unavailable_stream, create_custom_video_stream_response, - create_provider_connections_exhausted_stream, create_provider_stream, - get_custom_stream_response_error_status, get_stream_response_with_headers, is_custom_video_stream_enabled, - tee_stream, AppState, BoxedProviderStream, CustomVideoStreamType, PendingProviderReason, - ProviderAllocation, ProviderConfig, ProviderHandle, ProviderStreamCustomReason, - ProviderStreamFactoryOptions, ProviderStreamInfo, ProviderStreamState, SharedStreamCtx, - SharedStreamManager, StreamDetails, StreamError, StreamingStrategy, ThrottledStream, UserApiRequest, - UserSession, - }, - }, - auth::Fingerprint, - media_server::{ - playback::{ - media_server_image_response as open_media_server_proxy_image_response, - media_server_stream_response as open_media_server_proxy_stream_response, parse_media_server_image_ref, - parse_media_server_stream_ref, - }, - MediaServerError, MediaServerErrorKind, MediaServerHttpClient, MediaServerImageRef, - }, - model::{AppConfig, ConfigInput, ConfigTarget, InputUserInfo, ProxyUserCredentials}, - processing::{ - parser::hls::{rewrite_hls, RewriteHlsProps}, - processor::re_resolve_stalker_url, - }, - utils::{ - async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request, - request::{content_type_from_ext, parse_range, send_with_retry_and_provider}, - trace_if_enabled, - }, - BUILD_TIMESTAMP, -}; -use arc_swap::ArcSwapOption; -use axum::{ - body::Body, - http::{header, HeaderMap, HeaderName, HeaderValue, Response, StatusCode}, - response::IntoResponse, -}; -use bytes::{Bytes, BytesMut}; -use chrono::{DateTime, Utc}; -use futures::{stream, Stream, StreamExt, TryStreamExt}; -use log::{debug, error, info, log_enabled, trace, warn}; -use serde::Serialize; -use shared::{ - concat_string, - defaults::{DASH_EXT, HLS_EXT}, - model::{ - ConfigTargetOptions, InputFetchMethod, InputType, PlaylistEntry, PlaylistItemType, ProxyType, - StalkerStreamKind, StreamChannel, StreamInfo, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, - }, - utils::{ - bin_serialize, current_time_secs, extract_extension_from_url, get_credentials_from_url, human_readable_kbps, - is_sanitize_sensitive_info_enabled, replace_url_extension, sanitize_sensitive_info, trim_slash, Internable, - CONTENT_TYPE_CBOR, CONTENT_TYPE_JSON, - }, -}; -use smallvec::SmallVec; -use std::{ - borrow::Cow, - collections::HashMap, - convert::Infallible, - io::SeekFrom, - net::SocketAddr, - path::{Path, PathBuf}, - sync::{Arc, LazyLock}, - time::Duration, -}; -use tokio::{ - io::{AsyncReadExt, AsyncSeekExt}, - sync::{Mutex, RwLock}, -}; -use tokio_util::io::ReaderStream; -use tuliprox_hls::api::MAX_HLS_MANIFEST_BYTES; -use url::Url; - -/// Per-`(input id, provider id)` single-flight guards so concurrent client requests -/// for the same dead stalker stream trigger only one portal re-resolve at a time. -type StalkerResolveGuards = HashMap<(u16, u32), Arc>>; -static STALKER_RE_RESOLVE_GUARDS: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); - -pub(crate) fn resolve_request_url_for_logging<'a>(input: &ConfigInput, stream_url: &'a str) -> Cow<'a, str> { - if is_media_server_playback_url(input, stream_url) { - return Cow::Borrowed("media-server://"); - } - if is_sanitize_sensitive_info_enabled() { - return Cow::Borrowed(stream_url); - } - - let provider = input.get_resolve_provider(stream_url); - if let Ok(url) = Url::parse(stream_url) { - return Cow::Owned(request::preview_request_target_for_logging(&url, provider.as_ref())); - } - - input - .resolve_url(stream_url) - .ok() - .and_then(|resolved| { - Url::parse(resolved.as_ref()) - .ok() - .map(|url| Cow::Owned(request::preview_request_target_for_logging(&url, provider.as_ref()))) - }) - .unwrap_or(Cow::Borrowed(stream_url)) -} - -pub(crate) struct ConnectFailedAttempt<'a> { - pub app_state: &'a Arc, - pub fingerprint: &'a Fingerprint, - pub user: &'a ProxyUserCredentials, - pub stream_channel: StreamChannel, - pub provider_name: Arc, - pub req_headers: &'a HeaderMap, - pub reason: ConnectFailureReason, - pub failure_stage: FailureStage, -} - -pub(crate) fn record_connect_failed_attempt(attempt: ConnectFailedAttempt<'_>) { - let user_agent = attempt - .req_headers - .get(header::USER_AGENT) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - let info = StreamInfo::new(shared::model::StreamInfoParams { - uid: 0, - meter_uid: 0, - username: &attempt.user.username, - addr: &attempt.fingerprint.addr, - client_ip: &attempt.fingerprint.client_ip, - provider: attempt.provider_name, - stream_channel: attempt.stream_channel, - user_agent, - country_code: None, - session_token: None, - }); - // Resolve target_name from target_id using the stable target config name. - let target_name = - attempt.app_state.app_config.get_target_by_id(info.channel.target_id).as_deref().map(|t| (&t.name).intern()); - attempt.app_state.connection_manager.record_connect_failed_with_provider_failure( - &info, - attempt.reason, - attempt.failure_stage, - None, - None, - target_name, - ); -} - -fn admission_failure_video_type(reason: ConnectFailureReason) -> Option { - match reason { - ConnectFailureReason::UserAccountExpired => Some(CustomVideoStreamType::UserAccountExpired), - ConnectFailureReason::UserConnectionsExhausted => Some(CustomVideoStreamType::UserConnectionsExhausted), - ConnectFailureReason::ProviderConnectionsExhausted => Some(CustomVideoStreamType::ProviderConnectionsExhausted), - _ => None, - } -} - -pub(crate) fn admission_failure_response( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - stream_channel: StreamChannel, - provider_name: Arc, - req_headers: &HeaderMap, - reason: ConnectFailureReason, -) -> axum::response::Response { - record_connect_failed_attempt(ConnectFailedAttempt { - app_state, - fingerprint, - user, - stream_channel, - provider_name, - req_headers, - reason, - failure_stage: FailureStage::Admission, - }); - let Some(video_type) = admission_failure_video_type(reason) else { - error!("Unsupported admission failure reason: {reason:?}"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - }; - create_custom_video_stream_response(&app_state.provider_stream_ctx(), &fingerprint.addr, video_type).into_response() -} - -#[macro_export] -macro_rules! try_option_bad_request { - ($option:expr, $msg_is_error:expr, $msg:expr) => { - match $option { - Some(value) => value, - None => { - if $msg_is_error { - error!("{}", $msg); - } else { - debug!("{}", $msg); - } - return axum::http::StatusCode::BAD_REQUEST.into_response(); - } - } - }; - ($option:expr) => { - match $option { - Some(value) => value, - None => return axum::http::StatusCode::BAD_REQUEST.into_response(), - } - }; -} - -#[macro_export] -macro_rules! try_option_forbidden { - ($option:expr, $status:expr, $msg_is_error:expr, $msg:expr) => { - match $option { - Some(value) => value, - None => { - if $msg_is_error { - error!("{}", $msg); - } else { - debug!("{}", $msg); - } - return $status.into_response(); - } - } - }; - ($option:expr, $msg_is_error:expr, $msg:expr) => { - match $option { - Some(value) => value, - None => { - if $msg_is_error { - error!("{}", $msg); - } else { - debug!("{}", $msg); - } - return axum::http::StatusCode::FORBIDDEN.into_response(); - } - } - }; - ($option:expr) => { - match $option { - Some(value) => value, - None => return axum::http::StatusCode::FORBIDDEN.into_response(), - } - }; -} - -#[macro_export] -macro_rules! internal_server_error { - () => { - axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() - }; -} - -#[macro_export] -macro_rules! try_result_or_status { - ($option:expr, $status:expr, $msg_is_error:expr, $msg:expr) => { - match $option { - Ok(value) => value, - Err(_) => { - if $msg_is_error { - error!("{}", $msg); - } else { - debug!("{}", $msg); - } - return $status.into_response(); - } - } - }; - ($option:expr, $status:expr) => { - match $option { - Ok(value) => value, - Err(_) => return $status.into_response(), - } - }; -} - -#[macro_export] -macro_rules! try_result_bad_request { - ($option:expr, $msg_is_error:expr, $msg:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST, $msg_is_error, $msg) - }; - ($option:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST) - }; -} - -#[macro_export] -macro_rules! try_result_not_found { - ($option:expr, $msg_is_error:expr, $msg:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND, $msg_is_error, $msg) - }; - ($option:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND) - }; -} - -use crate::{ - api::{ - panel_api::{can_provision_on_exhausted, create_panel_api_provisioning_stream_details}, - static_headers::CT_OCTET, - }, - utils::LRUResourceCache, -}; -pub use internal_server_error; -use shared::{ - defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs}, - error::TuliproxError, - model::{ConnectFailureReason, FailureStage}, -}; -pub use try_option_bad_request; -pub use try_option_forbidden; -pub use try_result_bad_request; -pub use try_result_not_found; -pub use try_result_or_status; -// Moved to `tuliprox-core` so crates outside `api` can build responses too. -pub use tuliprox_core::try_unwrap_body; -// Admission moved to `tuliprox-session`, where the types it decides over -// already live. Re-exported so api call sites keep their names. -pub(crate) use tuliprox_core::utils::request_headers::{get_headers_from_request, HeaderFilter}; -pub(crate) use tuliprox_session::{ - admission::{ - classify_playback_request, connection_priority_for_kind, resolve_admission_with_strategies, - resolve_playback_request_admission, AdmissionRequest, EvictionReentryGuard, PlaybackRequestClass, - PlaybackRequestFacts, - }, - stream_options::{get_stream_options, StreamOptions}, -}; - -pub fn get_server_time() -> String { - chrono::offset::Local::now().with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %Z").to_string() -} - -static PROCESS_START: LazyLock = LazyLock::new(std::time::Instant::now); - -/// Anchors the uptime clock; call once at process startup. -pub fn init_uptime_clock() { let _ = *PROCESS_START; } - -pub fn get_uptime_secs() -> u64 { PROCESS_START.elapsed().as_secs() } - -pub fn get_build_time() -> Option { - BUILD_TIMESTAMP - .to_string() - .parse::>() - .ok() - .map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string()) -} - -// Response-compression opt-out moved to `tuliprox_core::utils`; re-exported so -// api call sites keep their names. -pub(crate) use tuliprox_core::utils::response_compression::{ - mark_response_as_uncompressed, should_compress_response_extensions, -}; - -#[derive(Clone, Copy, Debug, Default)] -struct StreamMeteringConfig { - meter_uid: u32, - meter_stream: bool, -} - -#[allow(clippy::missing_panics_doc)] -pub async fn serve_file(file_path: &Path, mime_type: String, cache_control: Option<&str>) -> impl IntoResponse + Send { - match tokio::fs::try_exists(file_path).await { - Ok(exists) => { - if !exists { - return StatusCode::NOT_FOUND.into_response(); - } - } - Err(err) => { - error!("Failed to open file {}, {err:?}", file_path.display()); - return StatusCode::NOT_FOUND.into_response(); - } - } - - match tokio::fs::File::open(file_path).await { - Ok(file) => { - let last_modified = file.metadata().await.ok().and_then(|m| m.modified().ok()).map(|m| { - let dt: DateTime = m.into(); - dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() - }); - - let reader = async_file_reader(file); - let stream = ReaderStream::new(reader); - let body = Body::from_stream(stream); - - let mut builder = axum::response::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime_type) - .header(header::CACHE_CONTROL, cache_control.unwrap_or("no-cache")); - - if let Some(lm) = last_modified { - builder = builder.header(header::LAST_MODIFIED, lm); - } - - try_unwrap_body!(builder.body(body)) - } - Err(_) => internal_server_error!(), - } -} - -pub fn get_user_target_by_username( - username: &str, - app_state: &Arc, -) -> Option<(Arc, Arc)> { - if !username.is_empty() { - return app_state.app_config.get_target_for_username(username); - } - None -} - -pub fn get_user_target_by_credentials<'a>( - username: &str, - password: &str, - api_req: &'a UserApiRequest, - app_state: &'a AppState, -) -> Option<(Arc, Arc)> { - if !username.is_empty() && !password.is_empty() { - app_state.app_config.get_target_for_user(username, password) - } else { - let token = api_req.token.as_str().trim(); - if token.is_empty() { - None - } else { - app_state.app_config.get_target_for_user_by_token(token) - } - } -} - -pub fn get_user_target<'a>( - api_req: &'a UserApiRequest, - app_state: &'a AppState, -) -> Option<(Arc, Arc)> { - let username = api_req.username.as_str().trim(); - let password = api_req.password.as_str().trim(); - get_user_target_by_credentials(username, password, api_req, app_state) -} - -struct StreamingAcquireOptions<'a> { - force_provider: Option<&'a Arc>, - allow_forced_provider_fallback: bool, - allow_provider_grace: bool, - user_priority: i8, - connection_kind: crate::api::model::ConnectionKind, - session_owner: Option<&'a str>, - accept_requested_stream_url: bool, -} - -pub struct ForceStreamRequestContext<'a> { - pub req_headers: &'a HeaderMap, - pub input: &'a Arc, - pub user: &'a ProxyUserCredentials, - pub session_reservation_ttl_secs: u64, - pub(crate) content_representation: crate::api::model::ProviderContentRepresentationMode, -} - -struct SessionActivationRequest<'a> { - fingerprint: &'a Fingerprint, - input: &'a ConfigInput, - user: &'a ProxyUserCredentials, - session_token: &'a str, - request_class: Option, - virtual_id: VirtualId, - item_type: PlaylistItemType, - stream_url: &'a str, - connection_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - socket_bound: bool, -} - -struct PlaybackActivationResult { - admission: crate::api::model::ConnectionAdmission, - grace_mode: Option, - grace_context: Option, - placeholder_transition_version: Option, -} - -/// # Panics -#[allow(clippy::too_many_lines)] -async fn activate_session_before_stream_open( - app_state: &Arc, - request: SessionActivationRequest<'_>, -) -> PlaybackActivationResult { - let SessionActivationRequest { - fingerprint, - input, - user, - session_token, - request_class, - virtual_id, - item_type, - stream_url, - connection_permission, - connection_kind, - socket_bound, - } = request; - // Classify based on current session state, not the pre-computed value. - // 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 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).await; - classify_playback_request(PlaybackRequestFacts { - existing_session: current_session.as_ref(), - prepare_only: false, - terminate: false, - }) - } else { - request_class - } - } else { - let existing_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; - classify_playback_request(PlaybackRequestFacts { - existing_session: existing_session.as_ref(), - prepare_only: false, - terminate: false, - }) - }; - let limits_enabled = app_state.app_config.config.load().user_access_control - && (user.max_connections > 0 || user.soft_connections > 0); - // Prepare: session setup without admission cost. The caller handles the actual activation. - // FollowUp: already counted, no re-admission needed. - // GracePeriod: grace already granted, no re-evaluation needed. - // No limits: skip admission entirely. - // GracePeriod permission is already resolved — skip admission strategies (re-run - // would evict the same session again). But we must still materialize the grace - // lifecycle (PendingProvider / GraceActive) so the session state is consistent. - if connection_permission == UserConnectionPermission::GracePeriod { - // Materialize grace lifecycle under the guard so the session state is consistent. - // Determine which grace mode applies by checking the current session state. - let current_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; - let (_, resolved_grace) = match current_session.as_ref().map(|s| &s.lifecycle) { - Some(crate::api::model::PlaybackLifecycle::PendingProvider { .. }) => { - // Session already in PendingProvider — refresh deadline. - let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); - let _ = app_state - .active_users - .mark_pending_provider(&user.username, session_token, PendingProviderReason::GraceHold, deadline) - .await; - ( - crate::api::model::PlaybackLifecycle::PendingProvider { - data: crate::api::model::PendingProviderState { - reason_code: PendingProviderReason::GraceHold, - created_at: current_time_secs(), - deadline, - version: current_session.as_ref().map_or(0, |s| { - if let crate::api::model::PlaybackLifecycle::PendingProvider { data } = &s.lifecycle { - data.version - } else { - 0 - } - }), - wake_source: None, - }, - }, - Some(crate::api::model::GraceMode::Hold), - ) - } - Some(crate::api::model::PlaybackLifecycle::GraceActive) => { - // 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. - // Live/LiveHls/LiveDash default to Hold; VOD/Catchup to Instant. - if item_type.is_live() || item_type.is_live_adaptive() { - let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); - let _ = app_state - .active_users - .mark_pending_provider( - &user.username, - session_token, - PendingProviderReason::GraceHold, - deadline, - ) - .await; - ( - crate::api::model::PlaybackLifecycle::PendingProvider { - data: crate::api::model::PendingProviderState { - reason_code: PendingProviderReason::GraceHold, - created_at: current_time_secs(), - deadline, - version: 1, - wake_source: None, - }, - }, - Some(crate::api::model::GraceMode::Hold), - ) - } else { - app_state.active_users.mark_grace_active(&user.username, session_token).await; - (crate::api::model::PlaybackLifecycle::GraceActive, Some(crate::api::model::GraceMode::Instant)) - } - } - }; - return PlaybackActivationResult { - admission: crate::api::model::ConnectionAdmission { - permission: connection_permission, - kind: Some(connection_kind), - }, - grace_mode: resolved_grace, - grace_context: None, - placeholder_transition_version: None, - }; - } - // No limits: skip admission entirely. FollowUp / Prepare: no re-admission needed. - if !limits_enabled - || effective_request_class == PlaybackRequestClass::FollowUp - || effective_request_class == PlaybackRequestClass::Prepare - { - return PlaybackActivationResult { - admission: crate::api::model::ConnectionAdmission { - permission: connection_permission, - kind: Some(connection_kind), - }, - grace_mode: None, - grace_context: None, - placeholder_transition_version: None, - }; - } - - let placeholder_transition_version = Some( - app_state - .active_users - .ensure_user_session_placeholder(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id: virtual_id.get(), - provider: input.name.as_ref(), - stream_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: Some(connection_kind), - socket_bound, - }) - .await, - ); - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: true, - session_token: Some(session_token), - activate_unbound_session: true, - eviction_reentry_guard: if socket_bound { - EvictionReentryGuard::SocketPlayback { virtual_id } - } else { - EvictionReentryGuard::Session(session_token) - }, - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - let grace_context = result.grace_context; - - if admission.permission == UserConnectionPermission::GracePeriod { - if matches!(grace_mode, Some(crate::api::model::GraceMode::Hold)) { - // Hold: session waits for provider slot. Does not count until provider is acquired. - let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); - let _ = app_state - .active_users - .mark_pending_provider(&user.username, session_token, PendingProviderReason::GraceHold, deadline) - .await; - } else if matches!(grace_mode, Some(crate::api::model::GraceMode::Instant)) { - // Instant: session is provisionally active immediately. Counts against admission limits - // until the grace window resolves (success -> Active, failure -> Expired). - app_state.active_users.mark_grace_active(&user.username, session_token).await; - } - } - - PlaybackActivationResult { admission, grace_mode, grace_context, placeholder_transition_version } -} - -pub fn get_stream_alternative_url( - stream_url: &str, - input: &ConfigInput, - alias_input: &Arc, -) -> Option { - if input.input_type.is_m3u() && input.get_matched_config_by_url(stream_url).is_none() { - return get_stream_alternative_url_m3u(stream_url, input, alias_input); - } - - let (source_base_url, source_username, source_password, matched_via_external_signature) = - if let Some(matched) = input.get_matched_config_by_url(stream_url) { - (matched.0.to_string(), matched.1.cloned(), matched.2.cloned(), false) - } else { - let (base_url, username, password) = find_input_account_by_signature(stream_url, input)?; - (base_url, username, password, true) - }; - if matched_via_external_signature && !input.input_type.is_m3u() { - return None; - } - 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()?; - - if let (Some(old_username), Some(old_password)) = (source_username, source_password) { - let auth_updated = rewrite_url_auth_fields( - &mut url, - &old_username, - &old_password, - &alt_input_user_info.username, - &alt_input_user_info.password, - ); - if !auth_updated { - return None; - } - } - - Some(url.to_string()) -} - -fn get_stream_alternative_url_m3u( - stream_url: &str, - input: &ConfigInput, - alias_input: &Arc, -) -> Option { - if let Some((source_base_url, source_username, source_password)) = - find_input_account_by_signature(stream_url, input) - { - let Some(alt_input_user_info) = alias_input.get_user_info() else { - return Some(stream_url.to_string()); - }; - let modified = stream_url.replacen(&source_base_url, &alt_input_user_info.base_url, 1); - let mut url = Url::parse(&modified).ok()?; - - if let (Some(old_username), Some(old_password)) = (source_username, source_password) { - let auth_updated = rewrite_url_auth_fields( - &mut url, - &old_username, - &old_password, - &alt_input_user_info.username, - &alt_input_user_info.password, - ); - if !auth_updated { - return None; - } - } - - return Some(url.to_string()); - } - let Some(alt_input_user_info) = alias_input.get_user_info() else { - let Ok(url) = Url::parse(stream_url) else { - return None; - }; - if providerless_m3u_url_has_explicit_credentials(&url) { - return None; - } - return Some(stream_url.to_string()); - }; - if stream_url_has_account_signature(stream_url, &alt_input_user_info) { - return None; - } - Some(stream_url.to_string()) -} - -fn providerless_m3u_url_has_explicit_credentials(url: &Url) -> bool { - !url.username().is_empty() - || url.password().is_some() - || url - .query_pairs() - .any(|(key, _)| key.eq_ignore_ascii_case("username") || key.eq_ignore_ascii_case("password")) -} - -/// Look for an account signature in the stream URL that matches the input -/// itself or one of its configured aliases. Returns the matching entry's -/// `(base_url, username, password)` so the caller can rewrite only the -/// account-specific parts of the URL while preserving the original host/path. -/// -/// This helper is used for safe credential rewrites when Tuliprox switches -/// from one account to another. It is not the general trust gate for M3U -/// foreign hosts: plain external URLs from a stored M3U playlist item may be -/// accepted without a matching signature, while unrelated credential-bearing -/// URLs still fail closed unless they provably match the input or one of its -/// aliases. -fn find_input_account_by_signature( - stream_url: &str, - input: &ConfigInput, -) -> Option<(String, Option, Option)> { - // Try the input's main account first. - if let Some(user_info) = input.get_user_info() { - if stream_url_account_matches(stream_url, &user_info) { - return Some((input.url.clone(), Some(user_info.username), Some(user_info.password))); - } - } - // Then try each alias, if any. The input_type is inherited from the - // parent input for all aliases — see ConfigInputAlias definition. - if let Some(aliases) = input.aliases.as_ref() { - for alias in aliases { - if let Some(user_info) = - InputUserInfo::new(input.input_type, alias.username.as_deref(), alias.password.as_deref(), &alias.url) - { - if stream_url_account_matches(stream_url, &user_info) { - return Some((alias.url.clone(), Some(user_info.username), Some(user_info.password))); - } - } - } - } - None -} - -fn rewrite_url_auth_fields( - url: &mut Url, - old_username: &str, - old_password: &str, - new_username: &str, - new_password: &str, -) -> bool { - if rewrite_query_auth_fields(url, new_username, new_password) { - return true; - } - - if url.username() == old_username && url.password() == Some(old_password) { - return url.set_username(new_username).is_ok() && url.set_password(Some(new_password)).is_ok(); - } - - rewrite_path_auth_fields(url, old_username, old_password, new_username, new_password) -} - -fn rewrite_query_auth_fields(url: &mut Url, new_username: &str, new_password: &str) -> bool { - let mut has_username = false; - let mut has_password = false; - let pairs: Vec<(String, String)> = url - .query_pairs() - .map(|(key, value)| { - if key.eq_ignore_ascii_case("username") { - has_username = true; - (key.into_owned(), new_username.to_string()) - } else if key.eq_ignore_ascii_case("password") { - has_password = true; - (key.into_owned(), new_password.to_string()) - } else { - (key.into_owned(), value.into_owned()) - } - }) - .collect(); - - if !(has_username && has_password) { - return false; - } - - url.query_pairs_mut().clear().extend_pairs(pairs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); - true -} - -fn collect_path_segments(url: &Url) -> Option> { - url.path_segments().map(|segments| segments.map(ToOwned::to_owned).collect::>()) -} - -fn find_path_auth_segment_index(segments: &[String], username: &str, password: &str) -> Option { - 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, - old_password: &str, - new_username: &str, - new_password: &str, -) -> bool { - let Some(mut segments) = collect_path_segments(url) else { - return false; - }; - - let credential_index = find_path_auth_segment_index(&segments, old_username, old_password); - let Some(credential_index) = credential_index else { - return false; - }; - - segments[credential_index] = new_username.to_string(); - segments[credential_index + 1] = new_password.to_string(); - - let Ok(mut path_segments) = url.path_segments_mut() else { - return false; - }; - path_segments.clear().extend(segments.iter().map(String::as_str)); - true -} - -fn stream_url_matches_provider(stream_url: &str, provider_cfg: &ProviderConfig) -> bool { - let Some(user_info) = provider_cfg.get_user_info() else { - return false; - }; - if stream_url_base_matches(stream_url, &user_info.base_url) { - // Same-host fast path: both base URL and account identity must match. - return stream_url_account_matches(stream_url, &user_info); - } - if !provider_cfg.input_type.is_m3u() { - return false; - } - // 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, &user_info) { - return stream_url_account_matches(stream_url, &user_info); - } - true -} - -fn stream_url_base_matches(stream_url: &str, base_url: &str) -> bool { - stream_url - .strip_prefix(base_url) - .is_some_and(|remaining| remaining.is_empty() || remaining.starts_with(['/', '?', '#'])) -} - -fn stream_url_account_matches(stream_url: &str, user_info: &crate::model::InputUserInfo) -> bool { - let Ok(url) = Url::parse(stream_url) else { - return false; - }; - - let (url_username, url_password) = get_credentials_from_url(&url); - if let (Some(url_username), Some(url_password)) = (url_username.as_deref(), url_password.as_deref()) { - return url_username == user_info.username && url_password == user_info.password; - } - - let mut has_query_username = false; - let mut has_query_password = false; - for (key, value) in url.query_pairs() { - if key.eq_ignore_ascii_case("username") { - has_query_username = value == user_info.username; - } else if key.eq_ignore_ascii_case("password") { - has_query_password = value == user_info.password; - } - } - if has_query_username || has_query_password { - return has_query_username && has_query_password; - } - - let Some(segments) = collect_path_segments(&url) else { - return false; - }; - - find_path_auth_segment_index(&segments, &user_info.username, &user_info.password).is_some() -} - -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; - }; - - let (url_username, url_password) = get_credentials_from_url(&url); - if url_username.is_some() && url_password.is_some() { - return true; - } - - let mut has_query_username = false; - let mut has_query_password = false; - for (key, _) in url.query_pairs() { - if key.eq_ignore_ascii_case("username") { - has_query_username = true; - } else if key.eq_ignore_ascii_case("password") { - has_query_password = true; - } - } - 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///...). 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 -} - -fn select_provider_stream_url( - stream_url: &str, - input: &ConfigInput, - provider_cfg: &Arc, - accept_requested_stream_url: bool, -) -> Option<(Arc, 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 { - get_stream_alternative_url(stream_url, input, provider_cfg).map(|url| (provider_cfg.name.clone(), url)) - } -} - -fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState { - ProviderStreamState::Custom { - response: create_channel_unavailable_stream(app_config, &[], StatusCode::OK), - reason: ProviderStreamCustomReason::UnmappedProviderUrl, - } -} - -async fn acquire_stream_provider_handle( - app_state: &Arc, - input: &ConfigInput, - fingerprint: &Fingerprint, - options: StreamingAcquireOptions<'_>, -) -> Option { - match options.force_provider { - Some(provider) => { - // First try to stay on the exact pinned provider account without over-allocating. - if let Some(handle) = app_state - .active_provider - .acquire_exact_connection_with_grace_for_session( - provider, - &fingerprint.addr, - options.allow_provider_grace, - options.user_priority, - options.connection_kind, - options.session_owner, - ) - .await - { - Some(handle) - } else if options.allow_forced_provider_fallback { - debug_if_enabled!( - "Pinned provider {} unavailable for {}; falling back to lineup allocation", - sanitize_sensitive_info(provider), - sanitize_sensitive_info(&fingerprint.addr.to_string()) - ); - app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &fingerprint.addr, - options.allow_provider_grace, - options.user_priority, - options.connection_kind, - options.session_owner, - ) - .await - } else { - debug_if_enabled!( - "Pinned provider {} unavailable for {}; strict provider affinity prevents fallback", - sanitize_sensitive_info(provider), - sanitize_sensitive_info(&fingerprint.addr.to_string()) - ); - None - } - } - None => { - app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &fingerprint.addr, - options.allow_provider_grace, - options.user_priority, - options.connection_kind, - options.session_owner, - ) - .await - } - } -} - -pub(crate) fn resolve_redirect_location<'a>( - input: Option<&ConfigInput>, - stream_url: &'a str, -) -> Result, TuliproxError> { - input.map_or(Ok(Cow::Borrowed(stream_url)), |input| input.resolve_url(stream_url)) -} - -async fn get_redirect_alternative_url( - app_state: &Arc, - redirect_url: &Arc, - input: &ConfigInput, -) -> Arc { - if let Some((base_url, username, password)) = input.get_matched_config_by_url(redirect_url) { - if let Some(provider_cfg) = app_state.active_provider.get_next_provider(&input.name).await { - let mut new_url = redirect_url.replacen(base_url, provider_cfg.url.as_str(), 1); - if let (Some(old_username), Some(old_password)) = (username, password) { - if let (Some(new_username), Some(new_password)) = - (provider_cfg.username.as_ref(), provider_cfg.password.as_ref()) - { - new_url = new_url.replacen(old_username, new_username, 1); - new_url = new_url.replacen(old_password, new_password, 1); - return new_url.into(); - } - // one has credentials the other not, something not right - return redirect_url.clone(); - } - return new_url.into(); - } - } - redirect_url.clone() -} - -/// Determines the appropriate streaming strategy for the given input and stream URL. -/// -/// This function attempts to acquire a connection to a streaming provider, either using a forced provider -/// (if specified), or based on the input name. It then selects a corresponding `StreamingOption`: -/// -/// - If no connections are available (`Exhausted`), it returns a custom stream indicating exhaustion. -/// - If a connection is available or in a grace period, it constructs a streaming URL accordingly: -/// - If the URL already targets the selected provider account, the original URL is reused. -/// - Otherwise, an alternative URL is generated based on the provider and input. -/// -/// The function returns: -/// - an optional `ProviderConnectionGuard` to manage the connection's lifecycle, -/// - a `ProviderStreamState` describing how the stream state is, -/// - and optional HTTP headers to include in the request. -/// -/// This logic helps abstract the decision-making behind provider selection and stream URL resolution. -async fn resolve_streaming_strategy( - app_state: &Arc, - stream_url: &str, - fingerprint: &Fingerprint, - input: &ConfigInput, - options: StreamingAcquireOptions<'_>, -) -> StreamingStrategy { - // allocate a provider connection - let accept_requested_stream_url = options.accept_requested_stream_url || input.input_type.is_stalker(); - 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 - - let mut release_failed_mapping = false; - let stream_response_params = if let Some(allocation) = provider_connection_handle.as_ref().map(|ph| &ph.allocation) - { - match allocation { - ProviderAllocation::Exhausted => { - debug!("Provider {} is exhausted. No connections allowed.", input.name); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom { response: stream, reason: ProviderStreamCustomReason::ProviderExhausted } - } - 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, accept_requested_stream_url) - { - debug_if_enabled!( - "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", - sanitize_sensitive_info(&input.name), - sanitize_sensitive_info(&provider_cfg.name), - sanitize_sensitive_info( - provider_cfg.get_user_info().as_ref().map_or_else(|| "?", |u| u.username.as_str()) - ), - allocation.short_key(), - sanitize_sensitive_info(resolve_request_url_for_logging(input, &url).as_ref()) - ); - - if matches!(allocation, ProviderAllocation::Available(_)) { - ProviderStreamState::Available(Some(selected_provider_name.intern()), url.intern()) - } else { - ProviderStreamState::GracePeriod(Some(selected_provider_name.intern()), url.intern()) - } - } else { - debug_if_enabled!( - "provider session rejected: input={} provider_cfg={} allocation={} stream_url={} reason=unmapped_provider_url", - sanitize_sensitive_info(&input.name), - sanitize_sensitive_info(&provider_cfg.name), - allocation.short_key(), - sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()) - ); - release_failed_mapping = true; - create_unmapped_provider_stream(&app_state.app_config) - } - } - } - } else { - debug!("Provider {} is exhausted. No connections allowed.", input.name); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom { response: stream, reason: ProviderStreamCustomReason::ProviderExhausted } - }; - - if release_failed_mapping { - if let Some(handle) = provider_connection_handle.take() { - let connection_manager = Arc::clone(&app_state.connection_manager); - tokio::spawn(async move { - connection_manager.release_provider_handle(Some(handle)).await; - }); - } - } - - StreamingStrategy { - provider_handle: provider_connection_handle, - provider_stream_state: stream_response_params, - input_headers: Some(input.headers.clone()), - } -} - -fn get_grace_period_millis( - connection_permission: UserConnectionPermission, - stream_response_params: &ProviderStreamState, - config_grace_period_millis: u64, -) -> u64 { - if config_grace_period_millis > 0 - && ( - matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period - || connection_permission == UserConnectionPermission::GracePeriod - // user grace period - ) - { - config_grace_period_millis - } else { - 0 - } -} - -fn should_defer_provider_open_for_grace_hold( - provider_grace_active: bool, - hold_stream: bool, - item_type: PlaylistItemType, - is_reopen: bool, -) -> bool { - if !(provider_grace_active && hold_stream) { - return false; - } - - // Catch-up must open immediately so its payload can be classified before response headers are committed. - if item_type == PlaylistItemType::Catchup { - return false; - } - - // v3.3.0 opened provider-affine VOD/Series reopens immediately, even when - // provider grace was temporarily in effect. Parking these requests in GracePending - // was introduced later and breaks players like libmpv during seek/reopen retries. - // Keep hold-stream behavior for live/admission paths, but restore direct-open behavior - // for provider-affine on-demand session reopens. - !(!item_type.is_live() && item_type.requires_provider_affinity() && is_reopen) -} - -fn should_refresh_stalker_playback(input_type: InputType, request_url_valid: bool, status: Option) -> bool { - input_type.is_stalker() && (!request_url_valid || status.is_some_and(|status| status.is_client_error())) -} - -fn needs_initial_stalker_resolution(input_type: InputType, stream_url: &str) -> bool { - input_type.is_stalker() && stream_url.is_empty() -} - -fn stalker_stream_kind(cluster: XtreamCluster, item_type: PlaylistItemType) -> StalkerStreamKind { - if item_type == PlaylistItemType::Catchup { - StalkerStreamKind::Archive - } else { - match cluster { - XtreamCluster::Live => StalkerStreamKind::Live, - XtreamCluster::Video => StalkerStreamKind::Movie, - XtreamCluster::Series => StalkerStreamKind::Episode, - } - } -} - -async fn re_resolve_stalker_url_singleflight( - app_state: &Arc, - input: &ConfigInput, - provider_id: u32, - kind: StalkerStreamKind, - force_refresh: bool, -) -> Result>, TuliproxError> { - let guard_key = (input.id, provider_id); - let entry_lock = { - let mut guards = STALKER_RE_RESOLVE_GUARDS.lock().await; - Arc::clone(guards.entry(guard_key).or_insert_with(|| Arc::new(Mutex::new(())))) - }; - let result = { - let _flight = entry_lock.lock().await; - let client = app_state.http_client.load().as_ref().clone(); - re_resolve_stalker_url(&app_state.app_config, &client, input, provider_id, kind, force_refresh).await - }; - drop(entry_lock); - let mut guards = STALKER_RE_RESOLVE_GUARDS.lock().await; - if guards.get(&guard_key).is_some_and(|lock| Arc::strong_count(lock) == 1) { - guards.remove(&guard_key); - } - result -} - -pub(crate) async fn resolve_initial_stalker_playback_url( - app_state: &Arc, - input: &ConfigInput, - provider_id: u32, - cluster: XtreamCluster, - item_type: PlaylistItemType, - stream_url: &Arc, -) -> Result, TuliproxError> { - if !needs_initial_stalker_resolution(input.input_type, stream_url) { - return Ok(Arc::clone(stream_url)); - } - re_resolve_stalker_url_singleflight(app_state, input, provider_id, stalker_stream_kind(cluster, item_type), false) - .await? - .ok_or_else(|| { - TuliproxError::RepositoryStalker(format!( - "Stalker playback URL could not be resolved for input '{}' and provider id {provider_id}", - input.name - )) - }) -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] -async fn create_stream_response_details( - app_state: &Arc, - stream_options: &StreamOptions, - stream_url: &str, - username: &str, - fingerprint: &Fingerprint, - req_headers: &HeaderMap, - input: &Arc, - stream_channel: &StreamChannel, - item_type: PlaylistItemType, - content_representation: crate::api::model::ProviderContentRepresentationMode, - share_stream: bool, - connection_permission: UserConnectionPermission, - force_provider: Option<&Arc>, - allow_forced_provider_fallback: bool, - allow_provider_grace: bool, - virtual_id: VirtualId, - user_priority: i8, - connection_kind: crate::api::model::ConnectionKind, - is_reopen: bool, - session_owner: Option<&str>, - session_headers: Option<&HashMap>, - accept_requested_stream_url: bool, - grace_hold_override: Option, - grace_resolution_context: Option, -) -> Result { - let mut streaming_strategy = resolve_streaming_strategy( - app_state, - stream_url, - fingerprint, - input, - StreamingAcquireOptions { - force_provider, - allow_forced_provider_fallback, - allow_provider_grace, - user_priority, - connection_kind, - session_owner, - accept_requested_stream_url, - }, - ) - .await; - let mut grace_period_options = app_state.get_grace_options(); - grace_period_options.period_millis = get_grace_period_millis( - connection_permission, - &streaming_strategy.provider_stream_state, - grace_period_options.period_millis, - ); - if let Some(hold) = grace_hold_override { - grace_period_options.hold_stream = hold; - } - let provider_grace_active = - matches!(streaming_strategy.provider_stream_state, ProviderStreamState::GracePeriod(_, _)); - - let guard_provider_name = - streaming_strategy.provider_handle.as_ref().and_then(|guard| guard.allocation.get_provider_name()); - - if matches!( - streaming_strategy.provider_stream_state, - ProviderStreamState::Custom { reason: ProviderStreamCustomReason::ProviderExhausted, .. } - ) && can_provision_on_exhausted(app_state, input) - { - if let Some(handle) = streaming_strategy.provider_handle.take() { - app_state.connection_manager.release_provider_handle(Some(handle)).await; - } - debug_if_enabled!( - "panel_api: provider connections exhausted; sending provisioning stream for input {}", - sanitize_sensitive_info(&input.name) - ); - let mut details = create_panel_api_provisioning_stream_details( - app_state, - input, - guard_provider_name.clone().or_else(|| Some(input.name.clone())), - &grace_period_options, - fingerprint.addr, - virtual_id, - ); - details.content_representation = content_representation; - return Ok(details); - } - - match streaming_strategy.provider_stream_state { - // custom stream means we display our own stream like connection exhausted, channel-unavailable... - ProviderStreamState::Custom { response: provider_stream, .. } => { - let (stream, stream_info) = provider_stream; - // When allocation is exhausted or no connection was acquired, guard_provider_name is None. - // Use input.name as fallback so the provider field is never empty. - let provider_name = guard_provider_name.clone().unwrap_or_else(|| input.name.clone()); - Ok(StreamDetails { - stream, - stream_info, - provider_name: Some(provider_name), - request_url: None, - session_headers: session_headers.cloned(), - provider_session_headers: HashMap::new(), - grace_period: grace_period_options, - provider_grace_active: false, - disable_provider_grace: false, - reconnect_flag: None, - provider_handle: streaming_strategy.provider_handle.clone(), - content_representation, - grace_resolution_context, - }) - } - ProviderStreamState::Available(_provider_name, request_url) - | ProviderStreamState::GracePeriod(_provider_name, request_url) => { - let mut request_url = request_url; - debug_if_enabled!( - "Provider stream selection: allocated_provider={} actual_request_url={}", - sanitize_sensitive_info(guard_provider_name.as_deref().unwrap_or("?")), - sanitize_sensitive_info(resolve_request_url_for_logging(input, request_url.as_ref()).as_ref()) - ); - let defer_provider_stream_until_grace_check = if should_defer_provider_open_for_grace_hold( - provider_grace_active, - grace_period_options.hold_stream, - item_type, - is_reopen, - ) { - if let Some(provider_name) = guard_provider_name.as_ref() { - app_state.active_provider.is_over_limit(provider_name).await - } else { - false - } - } else { - false - }; - let (stream, stream_info, provider_session_headers, reconnect_flag) = - if defer_provider_stream_until_grace_check { - debug_if_enabled!( - "Deferring provider stream open until grace check completes for {}", - sanitize_sensitive_info(resolve_request_url_for_logging(input, request_url.as_ref()).as_ref()) - ); - (None, None, HashMap::new(), None) - } else if is_media_server_stream_ref_url(request_url.as_ref()) { - match open_media_server_stream_for_input(app_state, input, request_url.as_ref(), req_headers).await - { - Ok((stream, stream_info)) => (Some(stream), stream_info, HashMap::new(), None), - Err(err) => { - error!("Can't open media-server stream: {err}"); - (None, None, HashMap::new(), None) - } - } - } else { - let parsed_url = Url::parse(&request_url); - let request_url_valid = parsed_url.is_ok(); - let ((mut stream, mut stream_info, mut provider_session_headers), mut reconnect_flag) = - if let Ok(url) = parsed_url { - let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); - let disabled_headers = app_state.get_disabled_headers(); - let mut provider_stream_factory_options = - ProviderStreamFactoryOptions::new(&crate::api::model::ProviderStreamFactoryParams { - addr: fingerprint.addr, - item_type, - share_stream, - stream_options, - 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), - client_ip: Some(&fingerprint.client_ip), - stream_channel: Some(stream_channel), - connect_failure_stage: Some(FailureStage::ProviderOpen), - content_representation, - }); - - let provider_config = input.get_resolve_provider(url.as_ref()); - provider_stream_factory_options.set_provider(provider_config); - if input.input_type.is_stalker() { - provider_stream_factory_options.require_public_destination(); - } - - let reconnect_flag = provider_stream_factory_options.get_reconnect_flag_clone(); - let provider_stream = match create_provider_stream( - &app_state.provider_stream_ctx(), - &app_state.http_client.load(), - provider_stream_factory_options, - ) - .await - { - None => (None, None, HashMap::new()), - Some(response) => { - (Some(response.stream), response.info, response.provider_session_headers) - } - }; - (provider_stream, Some(reconnect_flag)) - } else { - ((None, None, HashMap::new()), None) - }; - let should_refresh_stalker = should_refresh_stalker_playback( - input.input_type, - request_url_valid, - stream_info.as_ref().map(|(_, status, _, _)| *status), - ); - if should_refresh_stalker { - let force_stalker_refresh = - stream_info.as_ref().is_some_and(|(_, status, _, _)| status.is_client_error()); - let kind = stalker_stream_kind(stream_channel.cluster, item_type); - let resolve_result = re_resolve_stalker_url_singleflight( - app_state, - input, - stream_channel.provider_id, - kind, - force_stalker_refresh, - ) - .await; - match resolve_result { - Ok(Some(refreshed_url)) => { - if let Ok(url) = Url::parse(&refreshed_url) { - let default_user_agent = - app_state.app_config.config.load().default_user_agent.clone(); - let disabled_headers = app_state.get_disabled_headers(); - let mut options = ProviderStreamFactoryOptions::new( - &crate::api::model::ProviderStreamFactoryParams { - addr: fingerprint.addr, - item_type, - share_stream, - stream_options, - 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), - client_ip: Some(&fingerprint.client_ip), - stream_channel: Some(stream_channel), - connect_failure_stage: Some(FailureStage::ProviderOpen), - content_representation, - }, - ); - options.set_provider(input.get_resolve_provider(url.as_ref())); - options.require_public_destination(); - let retry_reconnect_flag = options.get_reconnect_flag_clone(); - let retried = create_provider_stream( - &app_state.provider_stream_ctx(), - &app_state.http_client.load(), - options, - ) - .await; - if let Some(response) = retried { - stream = Some(response.stream); - stream_info = response.info; - provider_session_headers = response.provider_session_headers; - reconnect_flag = Some(retry_reconnect_flag); - request_url = refreshed_url; - } else { - // Keep the original stream/stream_info: the upstream response - // might still be serveable, and its status is needed for reporting. - debug!("Stalker re-resolve retry could not open a stream, keeping original provider response"); - } - } - } - Ok(None) => {} - Err(err) => { - warn!( - "Failed to refresh Stalker playback URL: {}", - sanitize_sensitive_info(&err.to_string()) - ); - } - } - } - (stream, stream_info, provider_session_headers, reconnect_flag) - }; - - if log_enabled!(log::Level::Debug) { - if let Some((headers, status_code, response_url, _custom_video_type)) = stream_info.as_ref() { - debug!( - "Responding stream request {} with status {}, headers {:?}", - sanitize_sensitive_info(response_url.as_ref().map_or(stream_url, |s| s.as_str())), - status_code, - headers - ); - } - } - - // An intentional deferred open must retain its grace allocation until body polling - // resumes the provider request. Other failed opens release their allocation here. - let provider_handle = if stream.is_none() && !defer_provider_stream_until_grace_check { - 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)); - None - } else { - streaming_strategy.provider_handle.take() - }; - - Ok(StreamDetails { - stream, - stream_info, - provider_name: guard_provider_name.clone(), - request_url: Some(request_url.clone()), - session_headers: session_headers.cloned(), - provider_session_headers, - grace_period: grace_period_options, - provider_grace_active, - disable_provider_grace: false, - reconnect_flag, - provider_handle, - content_representation, - grace_resolution_context, - }) - } - } -} - -pub struct RedirectParams<'a, P> -where - P: PlaylistEntry, -{ - pub item: &'a P, - pub provider_id: Option, - pub cluster: XtreamCluster, - pub target_type: TargetType, - pub target: &'a ConfigTarget, - pub input: &'a ConfigInput, - pub user: &'a ProxyUserCredentials, - pub stream_ext: Option<&'a str>, - pub req_context: ApiStreamContext, - pub action_path: &'a str, -} - -impl

RedirectParams<'_, P> -where - P: PlaylistEntry, -{ - pub fn get_query_path(&self, provider_id: u32, url: &str) -> String { - let extension = self.stream_ext.map_or_else( - || extract_extension_from_url(url).map_or_else(String::new, ToString::to_string), - ToString::to_string, - ); - - // if there is an action_path (like for timeshift duration/start), it will be added in front of the stream_id - if self.action_path.is_empty() { - concat_string!(&provider_id.to_string(), &extension) - } else { - concat_string!(&trim_slash(self.action_path), "/", &provider_id.to_string(), &extension) - } - } -} - -pub async fn redirect_response<'a, P>( - app_state: &Arc, - params: &'a RedirectParams<'a, P>, -) -> Option -where - P: PlaylistEntry, -{ - let item_type = params.item.get_item_type(); - let provider_url = params.item.get_provider_url(); - if is_media_server_playback_url(params.input, provider_url.as_ref()) { - return None; - } - - let redirect_request = params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type); - let is_hls_request = item_type == PlaylistItemType::LiveHls || params.stream_ext == Some(HLS_EXT); - let is_dash_request = - (!is_hls_request && item_type == PlaylistItemType::LiveDash) || params.stream_ext == Some(DASH_EXT); - - if params.target_type == TargetType::M3u { - if redirect_request || is_dash_request { - let redirect_url: Arc = if is_hls_request { - replace_url_extension(&provider_url, HLS_EXT).into() - } else { - provider_url.clone() - }; - let redirect_url = - if is_dash_request { replace_url_extension(&redirect_url, DASH_EXT).into() } else { redirect_url }; - let redirect_url = get_redirect_alternative_url(app_state, &redirect_url, params.input).await; - let redirect_url = match resolve_redirect_location(Some(params.input), &redirect_url) { - Ok(url) => url, - Err(err) => { - error!("Failed to resolve redirect url: {}", sanitize_sensitive_info(&err.to_string())); - return Some(StatusCode::BAD_REQUEST.into_response()); - } - }; - debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(redirect_url.as_ref())); - return Some(redirect(redirect_url.as_ref()).into_response()); - } - } else if params.target_type == TargetType::Xtream { - let Some(provider_id) = params.provider_id else { - return Some(StatusCode::BAD_REQUEST.into_response()); - }; - - if redirect_request { - let target_name = params.target.name.as_str(); - let virtual_id = params.item.get_virtual_id(); - let stream_url = match get_xtream_player_api_stream_url( - params.input, - params.req_context, - ¶ms.get_query_path(provider_id, &provider_url), - &provider_url, - ) { - None => { - error!( - "Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", - params.req_context - ); - return Some(StatusCode::BAD_REQUEST.into_response()); - } - Some(url) => match app_state.active_provider.get_next_provider(¶ms.input.name).await { - Some(provider_cfg) => match get_stream_alternative_url(&url, params.input, &provider_cfg) { - Some(stream_url) => stream_url, - None => return Some(StatusCode::BAD_REQUEST.into_response()), - }, - None => url.to_string(), - }, - }; - let stream_url = match resolve_redirect_location(Some(params.input), &stream_url) { - Ok(url) => url, - Err(err) => { - error!("Failed to resolve redirect url: {}", sanitize_sensitive_info(&err.to_string())); - return Some(StatusCode::BAD_REQUEST.into_response()); - } - }; - - // hls or dash redirect - if is_dash_request { - let redirect_url = if is_hls_request { - &replace_url_extension(&stream_url, HLS_EXT) - } else { - &replace_url_extension(&stream_url, DASH_EXT) - }; - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(resolve_request_url_for_logging(params.input, redirect_url).as_ref()) - ); - return Some(redirect(redirect_url).into_response()); - } - - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(resolve_request_url_for_logging(params.input, stream_url.as_ref()).as_ref()) - ); - return Some(redirect(stream_url.as_ref()).into_response()); - } - } - - None -} - -fn is_media_server_playback_url(input: &ConfigInput, stream_url: &str) -> bool { - input.input_type == InputType::Plex || is_media_server_stream_ref_url(stream_url) -} - -fn is_media_server_stream_ref_url(stream_url: &str) -> bool { - Url::parse(stream_url).is_ok_and(|url| url.scheme() == "media-server") -} - -fn is_throttled_stream(item_type: PlaylistItemType, throttle_kbps: usize) -> bool { - throttle_kbps > 0 - && matches!( - item_type, - PlaylistItemType::Video - | PlaylistItemType::Series - | PlaylistItemType::SeriesInfo - | PlaylistItemType::Catchup - | PlaylistItemType::LocalVideo - | PlaylistItemType::LocalSeries - | PlaylistItemType::LocalSeriesInfo - ) -} - -fn prepare_body_stream(app_state: &Arc, item_type: PlaylistItemType, stream: S) -> axum::body::Body -where - S: futures::Stream> + Send + 'static, -{ - let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); - let body_stream = if is_throttled_stream(item_type, throttle_kbps) { - info!("Stream throttling active: {}", human_readable_kbps(u64::try_from(throttle_kbps).unwrap_or_default())); - axum::body::Body::from_stream(ThrottledStream::new(stream.boxed(), throttle_kbps)) - } else { - axum::body::Body::from_stream(stream) - }; - body_stream -} - -async fn open_media_server_stream_for_input( - app_state: &Arc, - input: &ConfigInput, - stream_url: &str, - req_headers: &HeaderMap, -) -> Result<(BoxedProviderStream, ProviderStreamInfo), MediaServerError> { - let stream_ref = parse_media_server_stream_ref(&input.name, stream_url)?; - let range = req_headers.get(header::RANGE).and_then(|value| value.to_str().ok()); - let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone()); - - let response = match input.input_type { - InputType::Plex => { - let client = input.plex_catalog_client(http_client)?; - open_media_server_proxy_stream_response(&client, &stream_ref, range).await? - } - InputType::Emby | InputType::Jellyfin => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("media-server playback proxy is not implemented for this input type")); - } - InputType::M3u - | InputType::Xtream - | InputType::M3uBatch - | InputType::XtreamBatch - | InputType::Stalker - | InputType::StalkerBatch - | InputType::Library - | InputType::Staged => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("playlist item is not backed by a media-server input")); - } - }; - - let headers = response - .headers - .iter() - .filter(|(key, _)| !is_hop_by_hop_response_header(key)) - .filter_map(|(key, value)| value.to_str().ok().map(|value| (key.to_string(), value.to_string()))) - .collect::>(); - let status = response.status; - let stream = response.body.map_err(|err| StreamError::Stream(err.to_string())).boxed(); - Ok((stream, Some((headers, status, None, None)))) -} - -fn is_hop_by_hop_response_header(name: &HeaderName) -> bool { - matches!( - name.as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "trailers" - | "transfer-encoding" - | "upgrade" - ) -} - -fn no_custom_video_fallback_status(app_config: &AppConfig) -> StatusCode { - // Two reasons we have no custom-video response: - // 1. Operator disabled `custom_stream_response_enabled` → return the - // configured fallback status (e.g. 502) so reverse proxies handle the - // socket consistently. - // 2. Operator enabled custom-video but the concrete resource is missing - // → return `400` so downstream `proxy_intercept_errors on;` (Nginx) - // can sever the socket instead of looping on `200 OK`. - // Collapsing both into the configured status code broke the Nginx-intercept - // contract that the operator relied on by enabling custom-video in the - // first place. - if is_custom_video_stream_enabled(app_config) { - StatusCode::BAD_REQUEST - } else { - get_custom_stream_response_error_status(app_config) - } -} - -/// # Panics -#[allow(clippy::too_many_lines)] -pub async fn force_provider_stream_response( - fingerprint: &Fingerprint, - app_state: &Arc, - user_session: &UserSession, - mut stream_channel: StreamChannel, - ctx: ForceStreamRequestContext<'_>, - grace_mode: Option, -) -> impl IntoResponse + Send { - let _transition_guard = - app_state.active_users.acquire_playback_transition(&ctx.user.username, &user_session.token).await; - let stream_options = get_stream_options(&app_state.app_config); - let share_stream = false; - let connection_permission = UserConnectionPermission::Allowed; - let item_type = stream_channel.item_type; - - // Forced reopens must clear stale provider slots before reacquiring. For adaptive HLS/DASH - // and Catchup sessions we only target old active stream sockets of the same session, never - // manifest-only session addresses, otherwise the controlling playlist request gets torn down. - let cleanup_addrs = if item_type.is_live_adaptive() || item_type == PlaylistItemType::Catchup { - app_state - .active_users - .adaptive_session_stream_cleanup_addrs(&ctx.user.username, &user_session.token, &fingerprint.addr) - .await - } else { - session_reacquire_cleanup_addrs(user_session, &fingerprint.addr) - }; - - if cleanup_addrs.is_empty() { - debug_if_enabled!( - "Forced reopen cleanup had no stale targets for item_type={item_type:?} session={} current_addr={}", - sanitize_sensitive_info(&user_session.token), - sanitize_sensitive_info(&fingerprint.addr.to_string()) - ); - } else { - debug_if_enabled!( - "Forced reopen cleanup releasing {} stale target(s) for item_type={item_type:?} session={} current_addr={}", - cleanup_addrs.len(), - sanitize_sensitive_info(&user_session.token), - sanitize_sensitive_info(&fingerprint.addr.to_string()) - ); - cleanup_forced_reopen_addrs(app_state, item_type, &cleanup_addrs).await; - } - - // Provider-affine playback must stay on the same provider account across seeks/range reconnects. - // Only non-affine sessions may fall back to a different account in the same lineup. - let preferred_provider = Some(&user_session.provider); - let allow_forced_provider_fallback = !item_type.requires_provider_affinity(); - // Never allow provider-side grace for forced seek/session reacquire. - // Over-allocation here would break provider-side one-connection limits. - let allow_provider_grace = false; - let connection_kind = user_session.connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); - - let stream_details = match create_stream_response_details( - app_state, - &stream_options, - &user_session.stream_url, - &ctx.user.username, - fingerprint, - ctx.req_headers, - ctx.input, - &stream_channel, - item_type, - ctx.content_representation, - share_stream, - connection_permission, - preferred_provider, - allow_forced_provider_fallback, - allow_provider_grace, - VirtualId::new(stream_channel.virtual_id), - connection_priority_for_kind(ctx.user, connection_kind), - 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, - ) - .await - { - Ok(stream_details) => stream_details, - Err(err) => { - app_state - .active_users - .release_unbound_session_reservation(&ctx.user.username, &user_session.token, None, false) - .await; - error!("Failed to stream: {err}"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - let deferred_grace_hold_stream = stream_details.has_deferred_provider_open(); - - if stream_details.has_stream() || deferred_grace_hold_stream { - let metering = prepare_stream_metering( - app_state, - user_session.stream_url.as_ref(), - share_stream, - stream_details.stream.is_some(), - stream_details.has_deferred_provider_open(), - ) - .await; - let provider_response = - stream_details.stream_info.as_ref().map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt)); - if ctx.session_reservation_ttl_secs > 0 { - if let Some(provider_name) = stream_details.provider_name.as_ref() { - app_state - .active_provider - .refresh_provider_reservation(provider_name, &user_session.token, ctx.session_reservation_ttl_secs) - .await; - } - } - app_state.active_users.update_session_addr(&ctx.user.username, &user_session.token, &fingerprint.addr).await; - stream_channel.shared = share_stream; - let socket_bound = user_session.socket_bound; - let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { - stream_details, - app_state, - user: ctx.user, - connection_permission, - connection_kind: user_session.connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal), - fingerprint, - stream_channel, - socket_bound, - session_token: Some(&user_session.token), - req_headers: ctx.req_headers, - meter_uid: metering.meter_uid, - meter_stream: metering.meter_stream, - }) - .await; - - let (status_code, header_map) = get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); - let mut response = axum::response::Response::builder().status(status_code); - for (key, value) in &header_map { - response = response.header(key, value); - } - - let body_stream = prepare_body_stream(app_state, item_type, stream); - debug_if_enabled!( - "Streaming provider forced stream request from {}", - sanitize_sensitive_info( - resolve_request_url_for_logging(ctx.input, user_session.stream_url.as_ref()).as_ref() - ) - ); - let mut response = try_unwrap_body!(response.body(body_stream)); - mark_response_as_uncompressed(&mut response); - return response; - } - - app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; - app_state - .active_users - .release_unbound_session_reservation(&ctx.user.username, &user_session.token, None, false) - .await; - if let (Some(stream), _stream_info) = - create_channel_unavailable_stream(&app_state.app_config, &[], StatusCode::SERVICE_UNAVAILABLE) - { - app_state - .connection_manager - .update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable) - .await; - debug!("Streaming custom stream"); - let mut response = try_unwrap_body!(axum::response::Response::builder() - .status(StatusCode::OK) - .body(axum::body::Body::from_stream(stream))); - mark_response_as_uncompressed(&mut response); - response - } else { - no_custom_video_fallback_status(&app_state.app_config).into_response() - } -} - -/// # Panics -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub(crate) async fn stream_response( - fingerprint: &Fingerprint, - app_state: &Arc, - session_token: &str, - request_class: Option, - mut stream_channel: StreamChannel, - stream_url: &str, - pinned_provider: Option<&Arc>, - req_headers: &HeaderMap, - input: &Arc, - target: &Arc, - user: &ProxyUserCredentials, - connection_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - allow_exhausted_shared_reconnect: bool, - grace_mode: Option, -) -> impl IntoResponse + Send { - let _transition_guard = app_state.active_users.acquire_playback_transition(&user.username, session_token).await; - let request_log_stream_url = resolve_request_url_for_logging(input, stream_url); - if log_enabled!(log::Level::Trace) { - trace!("Try to open stream {}", sanitize_sensitive_info(request_log_stream_url.as_ref())); - } - - let virtual_id = stream_channel.virtual_id; - let item_type = stream_channel.item_type; - let playback_extension = extract_extension_from_url(stream_url); - let socket_bound = is_socket_bound_playback_session(item_type, playback_extension); - let mut connection_permission = connection_permission; - let mut connection_kind = connection_kind; - let activation = activate_session_before_stream_open( - app_state, - SessionActivationRequest { - fingerprint, - input, - user, - session_token, - request_class, - virtual_id: VirtualId::new(virtual_id), - item_type, - stream_url, - connection_permission, - connection_kind, - socket_bound, - }, - ) - .await; - let grace_mode = activation.grace_mode.or(grace_mode); - connection_permission = activation.admission.permission; - connection_kind = activation.admission.kind.unwrap_or(connection_kind); - - let allow_shared_reuse = - connection_permission != UserConnectionPermission::Exhausted || allow_exhausted_shared_reconnect; - - let share_stream = is_stream_share_enabled(item_type, target); - let _shared_lock = if share_stream { - let write_lock = app_state.app_config.file_locks.write_lock_str(stream_url).await; - - if allow_shared_reuse { - if let Some(value) = try_shared_stream_response_if_any( - app_state, - stream_url, - fingerprint, - user, - connection_permission, - connection_kind, - stream_channel.clone(), - session_token, - req_headers, - ) - .await - { - return value.into_response(); - } - } - Some(write_lock) - } else { - // Opportunistic cross-target sharing: if another target already runs a shared stream - // for the same provider URL, subscribe to it instead of opening a separate connection. - if item_type == PlaylistItemType::Live && allow_shared_reuse { - if let Some(value) = try_shared_stream_response_if_any( - app_state, - stream_url, - fingerprint, - user, - connection_permission, - connection_kind, - stream_channel.clone(), - session_token, - req_headers, - ) - .await - { - debug_if_enabled!("Opportunistic shared stream reuse for {}", sanitize_sensitive_info(stream_url)); - return value.into_response(); - } - } - None - }; - - if connection_permission == UserConnectionPermission::Exhausted { - app_state - .active_users - .release_unbound_session_reservation( - &user.username, - session_token, - activation.placeholder_transition_version, - activation.placeholder_transition_version.is_some(), - ) - .await; - record_connect_failed_attempt(ConnectFailedAttempt { - app_state, - fingerprint, - user, - stream_channel: stream_channel.clone(), - provider_name: input.name.clone(), - req_headers, - reason: ConnectFailureReason::UserConnectionsExhausted, - failure_stage: FailureStage::Admission, - }); - return create_custom_video_stream_response( - &app_state.provider_stream_ctx(), - &fingerprint.addr, - CustomVideoStreamType::UserConnectionsExhausted, - ) - .into_response(); - } - - let stream_options = get_stream_options(&app_state.app_config); - 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, - stream_url, - &user.username, - fingerprint, - req_headers, - input, - &stream_channel, - item_type, - if item_type == PlaylistItemType::Catchup { - crate::api::model::ProviderContentRepresentationMode::Identity - } else { - crate::api::model::ProviderContentRepresentationMode::PreserveOrigin - }, - share_stream, - connection_permission, - pinned_provider, - pinned_provider.is_none(), - true, - VirtualId::new(stream_channel.virtual_id), - connection_priority_for_kind(user, connection_kind), - 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(), - ) - .await - { - Ok(stream_details) => stream_details, - Err(err) => { - app_state - .active_users - .release_unbound_session_reservation( - &user.username, - session_token, - activation.placeholder_transition_version, - activation.placeholder_transition_version.is_some(), - ) - .await; - error!("Failed to stream: {err}"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - if item_type == PlaylistItemType::Catchup { - if let Some(provider_stream) = stream_details.stream.take() { - let probe_deadline = Duration::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms().max(1)); - match probe_catchup_payload(provider_stream, probe_deadline).await { - Ok(CatchupPayload::Direct(provider_stream)) => stream_details.stream = Some(provider_stream), - Ok(CatchupPayload::HlsManifest(manifest)) => { - return detected_catchup_hls_response(DetectedCatchupHlsResponseParams { - app_state, - stream_details, - manifest, - user, - target, - input, - fingerprint, - session_token, - virtual_id: VirtualId::new(virtual_id), - connection_permission, - connection_kind, - fallback_stream_url: stream_url, - }) - .await; - } - Err(err) => { - error!("Failed to inspect catch-up payload: {err}"); - cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token) - .await; - return StatusCode::BAD_GATEWAY.into_response(); - } - } - } - } - - // When no provider stream is available, still create an ActiveClientStream if a grace period - // needs to resolve (provider-grace with hold_stream, or user-grace). The grace task will - // determine the correct mode (UserExhausted / ProviderExhausted / Inner) and serve the - // appropriate custom video or terminate cleanly. - let deferred_grace_hold_stream = - stream_details.has_deferred_provider_open() || connection_permission == UserConnectionPermission::GracePeriod; - - if stream_details.has_stream() || deferred_grace_hold_stream { - // let content_length = get_stream_content_length(provider_response.as_ref()); - let provider_response = stream_details - .stream_info - .as_ref() - .map(|(h, sc, response_url, cvt)| (h.clone(), *sc, response_url.clone(), *cvt)); - let provider_name = stream_details.provider_name.clone(); - let actual_request_url = stream_details.request_url.clone().unwrap_or_else(|| Arc::::from(stream_url)); - let log_actual_request_url = resolve_request_url_for_logging(input, actual_request_url.as_ref()); - - debug_if_enabled!( - "Provider request mapping: allocated_provider={} actual_request_url={}", - sanitize_sensitive_info(provider_name.as_deref().unwrap_or("?")), - sanitize_sensitive_info(log_actual_request_url.as_ref()) - ); - - if let Some((headers, status, _response_url, Some(CustomVideoStreamType::Provisioning))) = - stream_details.stream_info.as_ref() - { - debug_if_enabled!("panel_api provisioning response to client: status={} headers={:?}", status, headers); - } - - let metering = prepare_stream_metering( - app_state, - stream_url, - share_stream, - stream_details.stream.is_some(), - stream_details.has_deferred_provider_open(), - ) - .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() { - is_stream_shared = false; - } - } - let provider_handle = if is_stream_shared && !stream_details.has_deferred_provider_open() { - stream_details.provider_handle.take() - } else { - None - }; - - stream_channel.shared = is_stream_shared; - if is_stream_shared { - stream_channel.shared_joined_existing = Some(false); - stream_channel.shared_stream_id = Some(u64::from(metering.meter_uid)); - } else { - stream_channel.shared_joined_existing = None; - stream_channel.shared_stream_id = None; - } - let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { - stream_details, - app_state, - user, - connection_permission, - connection_kind, - fingerprint, - stream_channel, - socket_bound, - session_token: Some(session_token), - req_headers, - meter_uid: metering.meter_uid, - meter_stream: metering.meter_stream, - }) - .await; - let stream_resp = if is_stream_shared { - debug_if_enabled!( - "Streaming shared stream request from {}", - sanitize_sensitive_info(log_actual_request_url.as_ref()) - ); - // Shared Stream response - let shared_headers = provider_response.as_ref().map_or_else(Vec::new, |(h, _, _, _)| h.clone()); - if let Some((broadcast_stream, _shared_provider)) = SharedStreamManager::register_shared_stream( - SharedStreamCtx { - app_config: &app_state.app_config, - shared_stream_manager: &app_state.shared_stream_manager, - active_provider: &app_state.active_provider, - connection_manager: &app_state.connection_manager, - }, - stream_url, - stream, - &fingerprint.addr, - shared_headers, - stream_options.buffer_size, - provider_handle, - connection_priority_for_kind(user, connection_kind), - connection_kind, - ) - .await - { - let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); - let mut response = axum::response::Response::builder().status(status_code); - for (key, value) in &header_map { - response = response.header(key, value); - } - let mut response = try_unwrap_body!(response.body(axum::body::Body::from_stream(broadcast_stream))); - mark_response_as_uncompressed(&mut response); - response - } else { - StatusCode::BAD_REQUEST.into_response() - } - } else { - // Previously, we always persisted the provider's final request URL into the session. - // For VOD-like playback that can be the wrong thing to reuse later: a seek or reopen - // should start from the canonical playback entrypoint, not from a provider-specific - // redirected target that happened to be used for an earlier request. - // For Movies/Series/Catchup we therefore keep the canonical request URL in the session. - // That avoids "session poisoning" where later seeks/resumes inherit a non-canonical URL. - // For live playback we still keep the redirected URL when available, because staying on - // the chosen upstream edge/server is often desirable there. - let session_url: Cow<'_, str> = if matches!( - item_type, - PlaylistItemType::Catchup - | PlaylistItemType::Video - | PlaylistItemType::LocalVideo - | PlaylistItemType::Series - | PlaylistItemType::LocalSeries - | PlaylistItemType::SeriesInfo - | PlaylistItemType::LocalSeriesInfo - ) { - Cow::Owned(actual_request_url.to_string()) - } else { - provider_response - .as_ref() - .and_then(|(_, _, u, _)| u.as_ref()) - .map_or_else(|| Cow::Owned(actual_request_url.to_string()), |url| Cow::Owned(url.to_string())) - }; - let log_session_url = resolve_request_url_for_logging(input, session_url.as_ref()); - if log_enabled!(log::Level::Debug) { - if log_session_url.eq(log_actual_request_url.as_ref()) { - debug!( - "Streaming stream request from {}", - sanitize_sensitive_info(log_actual_request_url.as_ref()) - ); - } else { - debug!( - "Streaming stream request for {} from {}", - sanitize_sensitive_info(log_actual_request_url.as_ref()), - sanitize_sensitive_info(log_session_url.as_ref()) - ); - } - } - let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); - let mut response = axum::response::Response::builder().status(status_code); - for (key, value) in &header_map { - response = response.header(key, value); - } - - if let Some(provider) = provider_name { - if matches!( - item_type, - PlaylistItemType::LiveHls - | PlaylistItemType::LiveDash - | PlaylistItemType::Video - | PlaylistItemType::Series - | PlaylistItemType::SeriesInfo - | PlaylistItemType::LocalSeries - | PlaylistItemType::LocalSeriesInfo - | PlaylistItemType::Catchup - ) { - let _ = app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id, - provider: &provider, - stream_url: &session_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: Some(connection_kind), - socket_bound, - }) - .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; - } - } - } - } - - let body_stream = prepare_body_stream(app_state, item_type, stream); - let mut response = try_unwrap_body!(response.body(body_stream)); - mark_response_as_uncompressed(&mut response); - response - }; - - return stream_resp.into_response(); - } - app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; - app_state - .active_users - .release_unbound_session_reservation( - &user.username, - session_token, - activation.placeholder_transition_version, - activation.placeholder_transition_version.is_some(), - ) - .await; - no_custom_video_fallback_status(&app_state.app_config).into_response() -} - -enum CatchupPayload { - Direct(BoxedProviderStream), - HlsManifest(Bytes), -} - -struct DetectedCatchupHlsResponseParams<'a> { - app_state: &'a Arc, - stream_details: StreamDetails, - manifest: Bytes, - user: &'a ProxyUserCredentials, - target: &'a ConfigTarget, - input: &'a ConfigInput, - fingerprint: &'a Fingerprint, - session_token: &'a str, - virtual_id: VirtualId, - connection_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - fallback_stream_url: &'a str, -} - -async fn detected_catchup_hls_response(params: DetectedCatchupHlsResponseParams<'_>) -> axum::response::Response { - let DetectedCatchupHlsResponseParams { - app_state, - mut stream_details, - manifest, - user, - target, - input, - fingerprint, - session_token, - virtual_id, - connection_permission, - connection_kind, - fallback_stream_url, - } = params; - - let Some(provider) = stream_details.provider_name.clone() else { - cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; - return StatusCode::BAD_GATEWAY.into_response(); - }; - let Some(server_info) = app_state.app_config.get_user_server_info(user) else { - cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; - return StatusCode::BAD_GATEWAY.into_response(); - }; - let Ok(content) = std::str::from_utf8(&manifest) else { - cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; - return StatusCode::BAD_GATEWAY.into_response(); - }; - - let response_url = stream_details - .stream_info - .as_ref() - .and_then(|(_, _, response_url, _)| response_url.as_ref()) - .map_or_else(|| fallback_stream_url.to_string(), ToString::to_string); - let base_url = server_info.get_base_url(); - let encrypt_secret = app_state.get_encrypt_secret(); - let rewritten = rewrite_hls( - user, - &RewriteHlsProps { - secret: &encrypt_secret, - base_url: &base_url, - content, - hls_url: response_url, - target_id: target.id, - virtual_id: virtual_id.get(), - input_id: input.id, - user_token: Some(session_token), - }, - ); - - let request_url = stream_details.request_url.as_deref().unwrap_or(fallback_stream_url); - let created_session_token = app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id: virtual_id.get(), - provider: &provider, - stream_url: request_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: Some(connection_kind), - socket_bound: false, - }) - .await; - if !stream_details.provider_session_headers.is_empty() { - app_state - .active_users - .update_session_provider_headers( - &user.username, - &created_session_token, - &stream_details.provider_session_headers, - ) - .await; - } - app_state - .active_provider - .refresh_provider_reservation(&provider, &created_session_token, get_catchup_session_ttl_secs(app_state)) - .await; - app_state.connection_manager.release_provider_handle(stream_details.provider_handle.take()).await; - app_state - .active_users - .release_unbound_session_reservation(&user.username, &created_session_token, None, false) - .await; - app_state.active_users.clear_unbound_session_addr(&user.username, &created_session_token, &fingerprint.addr).await; - - catchup_hls_manifest_response(rewritten) -} - -fn catchup_hls_manifest_response(content: String) -> axum::response::Response { - let mut response = try_unwrap_body!(axum::response::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, crate::api::static_headers::CT_M3U.clone()) - .header(header::CACHE_CONTROL, crate::api::static_headers::CC_NO_STORE.clone()) - .body(Body::from(content))); - mark_response_as_uncompressed(&mut response); - response -} - -async fn cleanup_failed_detected_catchup_hls( - app_state: &Arc, - stream_details: &mut StreamDetails, - username: &str, - session_token: &str, -) { - app_state.connection_manager.release_provider_handle(stream_details.provider_handle.take()).await; - app_state.active_users.terminate_session(username, session_token).await; - app_state.active_provider.clear_provider_reservation(session_token).await; -} - -async fn probe_catchup_payload(stream: BoxedProviderStream, deadline: Duration) -> Result { - tokio::time::timeout(deadline, probe_catchup_payload_inner(stream)) - .await - .map_err(|_| StreamError::Stream("catch-up payload probe timed out".to_string()))? -} - -async fn probe_catchup_payload_inner(mut stream: BoxedProviderStream) -> Result { - const HLS_SIGNATURE: &[u8] = b"#EXTM3U"; - - let mut prefix = BytesMut::new(); - while prefix.len() < HLS_SIGNATURE.len() { - let Some(chunk) = stream.next().await else { - return Ok(CatchupPayload::Direct(stream::once(async move { Ok(prefix.freeze()) }).chain(stream).boxed())); - }; - prefix.extend_from_slice(&chunk?); - } - - if !prefix.starts_with(HLS_SIGNATURE) { - return Ok(CatchupPayload::Direct(stream::once(async move { Ok(prefix.freeze()) }).chain(stream).boxed())); - } - if prefix.len() > MAX_HLS_MANIFEST_BYTES { - return Err(StreamError::Stream("catch-up HLS manifest exceeds size limit".to_string())); - } - - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - if prefix.len().saturating_add(chunk.len()) > MAX_HLS_MANIFEST_BYTES { - return Err(StreamError::Stream("catch-up HLS manifest exceeds size limit".to_string())); - } - prefix.extend_from_slice(&chunk); - } - - Ok(CatchupPayload::HlsManifest(prefix.freeze())) -} - -fn get_stream_throttle(app_state: &Arc) -> u64 { - app_state - .app_config - .config - .load() - .reverse_proxy - .as_ref() - .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) - .map(|stream| stream.throttle_kbps) - .unwrap_or_default() -} - -fn is_stream_metrics_enabled(app_state: &Arc) -> bool { - app_state - .app_config - .config - .load() - .reverse_proxy - .as_ref() - .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) - .is_some_and(|stream| stream.metrics_enabled) -} - -async fn prepare_stream_metering( - app_state: &Arc, - stream_url: &str, - share_stream: bool, - has_stream: bool, - has_deferred_provider_open: bool, -) -> StreamMeteringConfig { - if !is_stream_metrics_enabled(app_state) { - return StreamMeteringConfig::default(); - } - - 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, meter_stream: has_stream || has_deferred_provider_open }; - } else if has_stream || has_deferred_provider_open { - let meter_uid = app_state.connection_manager.next_stream_uid(); - return StreamMeteringConfig { meter_uid, meter_stream: true }; - } - - StreamMeteringConfig::default() -} - -fn resolve_stream_config_u64( - stream_config: Option<&crate::model::StreamConfig>, - selector: impl FnOnce(&crate::model::StreamConfig) -> u64, - default_value: u64, -) -> u64 { - stream_config.map_or(default_value, selector) -} - -fn get_stream_config_u64( - app_state: &Arc, - selector: impl FnOnce(&crate::model::StreamConfig) -> u64, - default_value: u64, -) -> u64 { - let config = app_state.app_config.config.load(); - let stream_config = config.reverse_proxy.as_ref().and_then(|reverse_proxy| reverse_proxy.stream.as_ref()); - resolve_stream_config_u64(stream_config, selector, default_value) -} - -pub(crate) fn get_hls_session_ttl_secs(app_state: &Arc) -> u64 { - get_stream_config_u64(app_state, |stream| stream.hls_session_ttl_secs, default_hls_session_ttl_secs()) -} - -async fn cleanup_forced_reopen_addrs( - app_state: &Arc, - item_type: PlaylistItemType, - cleanup_addrs: &[SocketAddr], -) { - let close_client_socket = !(item_type.is_live_adaptive() || item_type == PlaylistItemType::Catchup); - for addr in cleanup_addrs { - app_state.connection_manager.release_provider_connection(addr).await; - if close_client_socket { - let _ = app_state.connection_manager.close_connection_signal(addr); - } - } -} - -pub(crate) fn get_catchup_session_ttl_secs(app_state: &Arc) -> u64 { - get_stream_config_u64(app_state, |stream| stream.catchup_session_ttl_secs, default_catchup_session_ttl_secs()) -} - -pub(crate) fn get_session_reservation_ttl_secs(app_state: &Arc, item_type: PlaylistItemType) -> u64 { - match item_type { - PlaylistItemType::LiveHls | PlaylistItemType::LiveDash => get_hls_session_ttl_secs(app_state), - PlaylistItemType::Catchup => get_catchup_session_ttl_secs(app_state), - _ => 0, - } -} - -/// 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, - _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, - stream_url: &str, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - connect_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - mut stream_channel: StreamChannel, - session_token: &str, - req_headers: &HeaderMap, -) -> Option { - if let Some((stream, provider)) = SharedStreamManager::subscribe_shared_stream( - SharedStreamCtx { - app_config: &app_state.app_config, - shared_stream_manager: &app_state.shared_stream_manager, - active_provider: &app_state.active_provider, - connection_manager: &app_state.connection_manager, - }, - stream_url, - &fingerprint.addr, - connection_priority_for_kind(user, connection_kind), - connection_kind, - ) - .await - { - debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url)); - if let Some(headers) = app_state.shared_stream_manager.get_shared_state_headers(stream_url).await { - let (status_code, header_map) = get_stream_response_with_headers(Some((headers.clone(), StatusCode::OK))); - let mut grace_period_options = app_state.get_grace_options(); - if connect_permission != UserConnectionPermission::GracePeriod { - grace_period_options.period_millis = 0; - } - let mut stream_details = StreamDetails::from_stream(stream, grace_period_options); - - stream_details.provider_name = provider; - let socket_bound = - is_socket_bound_playback_session(stream_channel.item_type, extract_extension_from_url(stream_url)); - if let Some(provider_name) = stream_details.provider_name.as_deref() { - let _ = app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id: stream_channel.virtual_id, - provider: provider_name, - stream_url, - addr: &fingerprint.addr, - connection_permission: connect_permission, - connection_kind: Some(connection_kind), - socket_bound, - }) - .await; - } - stream_channel.shared = true; - stream_channel.shared_joined_existing = Some(true); - 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, meter_stream: false }; - let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { - stream_details, - app_state, - user, - connection_permission: connect_permission, - connection_kind, - fingerprint, - stream_channel, - socket_bound, - session_token: Some(session_token), - req_headers, - meter_uid: metering.meter_uid, - meter_stream: metering.meter_stream, - }) - .await - .boxed(); - let mut response = axum::response::Response::builder().status(status_code); - for (key, value) in &header_map { - response = response.header(key, value); - } - let mut response = response.body(axum::body::Body::from_stream(stream)).ok()?; - mark_response_as_uncompressed(&mut response); - return Some(response); - } - } - None -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub(crate) async fn local_stream_response( - fingerprint: &Fingerprint, - app_state: &Arc, - pli: StreamChannel, - req_headers: &HeaderMap, - input: &ConfigInput, - _target: &ConfigTarget, - user: &ProxyUserCredentials, - connection_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - playback_session_token: Option<&str>, - request_class: Option, - check_path: bool, -) -> impl IntoResponse + Send { - let _transition_guard = if let Some(session_token) = playback_session_token { - Some(app_state.active_users.acquire_playback_transition(&user.username, session_token).await) - } else { - None - }; - if log_enabled!(log::Level::Trace) { - trace!("Try to open stream {}", sanitize_sensitive_info(&pli.url)); - } - - let mut connection_permission = connection_permission; - let mut grace_mode = None; - if connection_permission == UserConnectionPermission::Exhausted { - let allow_session_reopen = if let Some(session_token) = playback_session_token { - user.max_connections > 0 - && app_state - .active_users - .connection_permission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await - != UserConnectionPermission::Exhausted - } else { - false - }; - if !allow_session_reopen { - record_connect_failed_attempt(ConnectFailedAttempt { - app_state, - fingerprint, - user, - stream_channel: pli.clone(), - provider_name: input.name.clone(), - req_headers, - reason: ConnectFailureReason::UserConnectionsExhausted, - failure_stage: FailureStage::Admission, - }); - return create_custom_video_stream_response( - &app_state.provider_stream_ctx(), - &fingerprint.addr, - CustomVideoStreamType::UserConnectionsExhausted, - ) - .into_response(); - } - connection_permission = UserConnectionPermission::Allowed; - } - - 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 canonical = match tokio::fs::canonicalize(&path).await { - Ok(canonical) => canonical, - Err(err) => { - error!("Local file path is corrupt {}: {err}", path.display()); - return StatusCode::NOT_FOUND.into_response(); - } - }; - - if check_path { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let Ok(canonical_metadata) = tokio::fs::metadata(&canonical).await else { return internal_server_error!() }; - 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(); - } - } - #[cfg(windows)] - match same_windows_file_identity(&file, &canonical).await { - Ok(true) => {} - Ok(false) => { - error!("TOCTOU race detected: file swapped during local_stream_response"); - return StatusCode::FORBIDDEN.into_response(); - } - Err(err) => { - error!("Could not verify local file identity {}: {err}", canonical.display()); - return internal_server_error!(); - } - } - #[cfg(not(any(unix, windows)))] - { - error!("Secure local file identity validation is unsupported on this platform"); - return StatusCode::FORBIDDEN.into_response(); - } - - let Some(library_paths) = app_state - .app_config - .config - .load() - .library - .as_ref() - .map(|lib| lib.scan_directories.iter().map(|dir| dir.path.clone()).collect::>()) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - - // Verify path is within allowed media directories - // (requires configuration of allowed base paths) - if !is_path_within_allowed_directories(&canonical, &library_paths) { - return StatusCode::FORBIDDEN.into_response(); - } - } - - let file_size = opened_metadata.len(); - - let range = req_headers.get("range").and_then(|v| v.to_str().ok()).and_then(parse_range); - - let (start, end) = if let Some((req_start, req_end)) = range { - if file_size == 0 || req_start >= file_size { - return StatusCode::RANGE_NOT_SATISFIABLE.into_response(); - } - let end = req_end.unwrap_or(file_size - 1).min(file_size - 1); - if end < req_start { - return StatusCode::RANGE_NOT_SATISFIABLE.into_response(); - } - (req_start, end) - } else { - if file_size == 0 { - // Serve empty file - let body = axum::body::Body::empty(); - let mut response = Response::new(body); - *response.status_mut() = StatusCode::OK; - let headers = response.headers_mut(); - if let Some(ext) = get_file_extension(&pli.url) { - let ct = content_type_from_ext(&ext); - headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); - } else { - headers.insert(header::CONTENT_TYPE, CT_OCTET.clone()); //HeaderValue::from_static("application/octet-stream")); - } - headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("0")); - return response.into_response(); - } - (0, file_size - 1) - }; - - let content_length = end - start + 1; - - if start > 0 { - if let Err(_err) = file.seek(SeekFrom::Start(start)).await { - return internal_server_error!(); - } - } - - let stream = - ReaderStream::new(file.take(content_length)).map_err(|err| StreamError::Stream(err.to_string())).boxed(); - let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); - let stream = if is_throttled_stream(pli.item_type, throttle_kbps) { - info!("Stream throttling active: {}", human_readable_kbps(u64::try_from(throttle_kbps).unwrap_or_default())); - ThrottledStream::new(stream, throttle_kbps).boxed() - } else { - stream - }; - let socket_bound = is_socket_bound_playback_session(pli.item_type, extract_extension_from_url(&pli.url)); - let mut connection_kind = connection_kind; - if let Some(session_token) = playback_session_token { - let activation = activate_session_before_stream_open( - app_state, - SessionActivationRequest { - fingerprint, - input, - user, - session_token, - request_class, - virtual_id: VirtualId::new(pli.virtual_id), - item_type: pli.item_type, - stream_url: &pli.url, - connection_permission, - connection_kind, - socket_bound, - }, - ) - .await; - grace_mode = activation.grace_mode; - connection_permission = activation.admission.permission; - connection_kind = activation.admission.kind.unwrap_or(connection_kind); - - if connection_permission == UserConnectionPermission::Exhausted { - app_state - .active_users - .release_unbound_session_reservation( - &user.username, - session_token, - activation.placeholder_transition_version, - activation.placeholder_transition_version.is_some(), - ) - .await; - return create_custom_video_stream_response( - &app_state.provider_stream_ctx(), - &fingerprint.addr, - CustomVideoStreamType::UserConnectionsExhausted, - ) - .into_response(); - } - } - let mut grace_period_options = app_state.get_grace_options(); - if connection_permission != UserConnectionPermission::GracePeriod { - grace_period_options.period_millis = 0; - } - if let Some(resolved_mode) = grace_mode { - grace_period_options.hold_stream = matches!(resolved_mode, crate::api::model::GraceMode::Hold); - } - let resolved_connection_kind = if let Some(session_token) = playback_session_token { - app_state - .active_users - .get_and_update_user_session(&user.username, session_token) - .await - .and_then(|session| session.connection_kind) - .unwrap_or(connection_kind) - } else { - connection_kind - }; - if let Some(session_token) = playback_session_token { - let _ = app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id: pli.virtual_id, - provider: input.name.as_ref(), - stream_url: &pli.url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: Some(resolved_connection_kind), - socket_bound, - }) - .await; - } - let metering = prepare_stream_metering(app_state, &pli.url, false, true, false).await; - let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { - stream_details: StreamDetails::from_stream(stream, grace_period_options), - app_state, - user, - connection_permission, - connection_kind: resolved_connection_kind, - fingerprint, - stream_channel: pli.clone(), - socket_bound, - session_token: playback_session_token, - req_headers, - meter_uid: metering.meter_uid, - meter_stream: metering.meter_stream, - }) - .await; - - let mut response = Response::new(axum::body::Body::from_stream(stream)); - - *response.status_mut() = if range.is_some() { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; - - let headers = response.headers_mut(); - if let Some(ext) = get_file_extension(&pli.url) { - let ct = content_type_from_ext(&ext); - headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); - } else { - headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); - } - headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - if let Ok(header_value) = HeaderValue::from_str(&content_length.to_string()) { - headers.insert(header::CONTENT_LENGTH, header_value); - } - - if range.is_some() { - if let Ok(header_value) = HeaderValue::from_str(&format!("bytes {start}-{end}/{file_size}")) { - headers.insert(header::CONTENT_RANGE, header_value); - } - } - - mark_response_as_uncompressed(&mut response); - response -} - -fn is_path_within_allowed_directories(sub_path: &Path, root_paths: &[String]) -> bool { - for root_path in root_paths { - if sub_path.starts_with(PathBuf::from(root_path)) { - return true; - } - } - false -} - -pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { - (item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */) - && target.options.as_ref().is_some_and(ConfigTargetOptions::share_live_mpeg_ts_enabled) -} - -pub fn is_hls_stream_share_enabled(target: &ConfigTarget) -> bool { - target.options.as_ref().is_some_and(ConfigTargetOptions::share_live_hls_enabled) -} - -fn get_add_cache_content( - res_url: &str, - mime_type: Option, - cache: &Arc>>, -) -> Arc { - let resource_url = String::from(res_url); - let cache = Arc::clone(cache); - let add_cache_content: Arc = Arc::new(move |size| { - let res_url = resource_url.clone(); - let mime_type = mime_type.clone(); - // todo spawn, replace with unboundchannel - let cache = Arc::clone(&cache); - tokio::spawn(async move { - if let Some(cache) = cache.load().as_ref() { - let _ = cache.write().await.add_content(&res_url, mime_type, size); - } - }); - }); - add_cache_content -} - -fn get_mime_type(headers: &HeaderMap, resource_url: &str) -> Option { - headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) // Option<&str> - .map(ToString::to_string) // Option - .or_else(|| { - // fallback to guess - mime_guess::from_path(resource_url).first_raw().map(ToString::to_string) - }) -} - -#[cfg(windows)] -async fn same_windows_file_identity(opened_file: &tokio::fs::File, canonical_path: &Path) -> std::io::Result { - let canonical_file = tokio::fs::File::open(canonical_path).await?; - Ok(windows_file_identity(opened_file)? == windows_file_identity(&canonical_file)?) -} - -#[cfg(windows)] -fn windows_file_identity(file: &tokio::fs::File) -> std::io::Result<(u32, u32, u32)> { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; - - let mut info = unsafe { std::mem::zeroed::() }; - // SAFETY: `file.as_raw_handle()` is a live file handle for the duration of - // the call, and `info` is a writable output buffer for the WinAPI function. - let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &raw mut info) }; - if ok == 0 { - return Err(std::io::Error::last_os_error()); - } - Ok((info.dwVolumeSerialNumber, info.nFileIndexHigh, info.nFileIndexLow)) -} - -async fn build_resource_stream_response( - app_state: &Arc, - resource_url: &str, - response: reqwest::Response, -) -> axum::response::Response { - let sanitized_resource_url = sanitize_sensitive_info(resource_url); - let status = response.status(); - let mut response_builder = axum::response::Response::builder().status(status); - let mime_type = get_mime_type(response.headers(), resource_url); - let has_content_range = response.headers().contains_key(header::CONTENT_RANGE); - for (key, value) in response.headers() { - if !is_hop_by_hop_response_header(key) { - response_builder = response_builder.header(key, value); - } - } - - if !response_builder.headers_ref().is_some_and(|h| h.contains_key(header::CACHE_CONTROL)) { - response_builder = response_builder.header(header::CACHE_CONTROL, "public, max-age=14400"); - } - - let byte_stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); - // Cache only complete responses (200 OK without Content-Range) - let can_cache = status == StatusCode::OK && !has_content_range; - if can_cache { - debug!("Caching eligible resource stream {sanitized_resource_url}"); - let cache_resource_path = if let Some(cache) = app_state.cache.load().as_ref() { - Some(cache.write().await.store_path(resource_url, mime_type.as_deref())) - } else { - None - }; - if let Some(resource_path) = cache_resource_path { - match create_new_file_for_write(&resource_path).await { - Ok(file) => { - debug!("Persisting resource stream {sanitized_resource_url} to {}", resource_path.display()); - let writer = async_file_writer(file); - let add_cache_content = get_add_cache_content(resource_url, mime_type, &app_state.cache); - let tee = tee_stream(byte_stream, writer, &resource_path, add_cache_content); - return try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(tee))); - } - Err(err) => { - warn!( - "Failed to create cache file {} for {sanitized_resource_url}: {err}", - resource_path.display() - ); - } - } - } else { - debug!("Resource cache unavailable; streaming response for {sanitized_resource_url} without persistence"); - } - } - - try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(byte_stream))) -} - -async fn fetch_resource_with_retry( - app_state: &Arc, - url: &Url, - resource_url: &str, - req_headers: &HashMap>, - input: Option<&ConfigInput>, -) -> Option { - let config = app_state.app_config.config.load(); - let default_user_agent = config.default_user_agent.clone(); - drop(config); - - let disabled_headers = app_state.get_disabled_headers(); - - let provider_config = input.and_then(|i| i.get_resolve_provider(url.as_str())); - let Ok(response) = - send_with_retry_and_provider(&app_state.app_config, url, provider_config.as_ref(), false, |resolved_url| { - request::get_client_request( - &app_state.http_client.load(), - input.map_or(InputFetchMethod::GET, |i| i.method), - input.map(|i| &i.headers), - resolved_url, - Some(req_headers), - disabled_headers.as_ref(), - default_user_agent.as_deref(), - ) - }) - .await - else { - return None; - }; - - let status = response.status(); - - if status.is_success() { - return Some(build_resource_stream_response(app_state, resource_url, response).await); - } - - // Non-retriable Status -> Upstream Response incl. Body - debug_if_enabled!("Failed to open resource got status {status} for {}", sanitize_sensitive_info(resource_url)); - - let mut response_builder = axum::response::Response::builder().status(status); - for (key, value) in response.headers() { - if !is_hop_by_hop_response_header(key) { - response_builder = response_builder.header(key, value); - } - } - - let stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); - - Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream)))) -} - -/// # Panics -pub async fn resource_response( - app_state: &Arc, - resource_url: &str, - req_headers: &HeaderMap, - input: Option<&ConfigInput>, -) -> impl IntoResponse + Send { - if resource_url.is_empty() { - return StatusCode::NO_CONTENT.into_response(); - } - if resource_url.starts_with("media-server://image/") { - return match open_media_server_image_resource(app_state, resource_url).await { - Ok(response) => response, - Err(err) => { - let status = media_server_image_error_status(&err); - match status { - StatusCode::BAD_REQUEST => warn!("Invalid media-server image resource URL: {err}"), - StatusCode::NOT_FOUND => debug!("Media-server image resource was not found: {err}"), - _ => error!("Can't open media-server image from upstream: {err}"), - } - status.into_response() - } - }; - } - let filter: HeaderFilter = Some(Box::new(|key| key != "if-none-match" && key != "if-modified-since")); - let req_headers = get_headers_from_request(req_headers, &filter); - if let Some(cache) = app_state.cache.load().as_ref() { - let cache_hit = { - let mut guard = cache.write().await; - guard.get_content(resource_url) - }; - - if let Some((resource_path, mime_type)) = cache_hit { - trace_if_enabled!("Responding resource from cache {}", sanitize_sensitive_info(resource_url)); - return serve_file( - &resource_path, - mime_type.unwrap_or_else(|| mime::APPLICATION_OCTET_STREAM.to_string()), - Some("public, max-age=14400"), - ) - .await - .into_response(); - } - } - trace_if_enabled!("Try to fetch resource {}", sanitize_sensitive_info(resource_url)); - if let Ok(url) = Url::parse(resource_url) { - if let Some(resp) = fetch_resource_with_retry(app_state, &url, resource_url, &req_headers, input).await { - return resp; - } - // Upstream failure after retries - return StatusCode::BAD_GATEWAY.into_response(); - } - error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); - StatusCode::BAD_REQUEST.into_response() -} - -async fn open_media_server_image_resource( - app_state: &Arc, - resource_url: &str, -) -> Result, MediaServerError> { - let image_ref = parse_media_server_image_ref(resource_url)?; - let input_name = media_server_image_input_name(&image_ref); - let input = app_state.app_config.get_input_by_name(input_name).ok_or_else(|| { - MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) - .provider("media-server") - .detail("media-server image input was not found") - })?; - let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone()); - - let response = match input.input_type { - InputType::Plex => { - let client = input.plex_catalog_client(http_client)?; - open_media_server_proxy_image_response(&client, &image_ref).await? - } - InputType::Emby | InputType::Jellyfin => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("media-server image proxy is not implemented for this input type")); - } - InputType::M3u - | InputType::Xtream - | InputType::M3uBatch - | InputType::XtreamBatch - | InputType::Stalker - | InputType::StalkerBatch - | InputType::Library - | InputType::Staged => { - return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail("media-server image input is not backed by a media-server input")); - } - }; - - let mut builder = Response::builder().status(response.status); - for (key, value) in &response.headers { - if !is_hop_by_hop_response_header(key) { - builder = builder.header(key, value); - } - } - let body = response.body.map_err(|err| StreamError::Stream(err.to_string())); - builder.body(Body::from_stream(body)).map_err(|err| { - MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .provider("media-server") - .detail(format!("media-server image response build failed: {err}")) - }) -} - -fn media_server_image_error_status(err: &MediaServerError) -> StatusCode { - match err.kind { - MediaServerErrorKind::MediaServerItemNotFound | MediaServerErrorKind::NoDirectPlayableMediaServerSource => { - StatusCode::NOT_FOUND - } - MediaServerErrorKind::MediaServerStreamOpenFailed if is_media_server_image_validation_error(err) => { - StatusCode::BAD_REQUEST - } - MediaServerErrorKind::MediaServerStreamOpenFailed - | MediaServerErrorKind::MediaServerAuthDenied - | MediaServerErrorKind::MediaServerUnavailable - | MediaServerErrorKind::MediaServerLibraryUnavailable - | MediaServerErrorKind::MediaServerLibraryTypeUnsupported - | MediaServerErrorKind::MediaServerCatalogDecodeFailed - | MediaServerErrorKind::MediaServerCatalogPageStalled - | MediaServerErrorKind::MediaServerCatalogIncomplete - | MediaServerErrorKind::MediaServerRateLimited - | MediaServerErrorKind::MediaServerDiscoveryFailed => StatusCode::BAD_GATEWAY, - } -} - -fn is_media_server_image_validation_error(err: &MediaServerError) -> bool { - err.detail_text().is_some_and(|detail| { - detail.contains("resource URL is not a media server image URL") - || detail.contains("media server image URL is missing required path parts") - || detail.contains("unsupported media server image URL scheme") - || detail.contains("media-server image input is not backed by a media-server input") - }) -} - -fn media_server_image_input_name(image_ref: &MediaServerImageRef) -> &Arc { - match image_ref { - MediaServerImageRef::Emby { input_name, .. } - | MediaServerImageRef::Jellyfin { input_name, .. } - | MediaServerImageRef::Plex { input_name, .. } => input_name, - } -} - -pub fn separate_number_and_remainder(input: &str) -> (&str, Option<&str>) { - input.rfind('.').map_or_else( - || (input, None), - |dot_index| { - let number_part = &input[..dot_index]; - let rest = &input[dot_index..]; - (number_part, if rest.len() < 2 { None } else { Some(rest) }) - }, - ) -} - -/// # Panics -pub fn empty_json_list_response() -> axum::response::Response { - try_unwrap_body!(axum::response::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) - .body("[]".to_owned())) -} - -pub fn get_username_from_auth_header(token: &str, app_state: &Arc) -> Option { - let config = app_state.app_config.config.load(); - let web_auth_config = config.web_ui.as_ref()?.auth.as_ref()?; - // This hand-rolled its own `decode` with a bare `Validation::new`, which - // checks `exp` and nothing else - no issuer. - crate::auth::verify_token(token, web_auth_config.secret.as_bytes(), &web_auth_config.issuer) - .map(|token_data| token_data.claims.username) -} - -pub fn redirect(url: &str) -> impl IntoResponse { - try_unwrap_body!(axum::response::Response::builder() - .status(StatusCode::FOUND) - .header(header::LOCATION, url) - .body(Body::empty())) -} - -pub fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> bool { - // seek only for non-live streams - if cluster == XtreamCluster::Live { - return false; - } - - // seek requests contains range header - let range = req_headers.get("range").and_then(|h| h.to_str().ok()).map(ToString::to_string); - - if let Some(range) = range { - if range.starts_with("bytes=") { - return true; - } - } - false -} - -pub fn is_seekable_media_request(cluster: XtreamCluster, req_headers: &HeaderMap, extension: Option<&str>) -> bool { - !extension.is_some_and(|ext| ext.eq_ignore_ascii_case(HLS_EXT)) && is_seek_request(cluster, req_headers) -} - -pub fn bin_response(data: &T) -> impl IntoResponse + Send { - match bin_serialize(data) { - Ok(body) => ([(header::CONTENT_TYPE, CONTENT_TYPE_CBOR)], body).into_response(), - Err(_) => internal_server_error!(), - } -} - -pub fn json_response(data: &T) -> impl IntoResponse + Send { - (StatusCode::OK, axum::Json(data)).into_response() -} - -pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> impl IntoResponse + Send { - if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { - return bin_response(data).into_response(); - } - json_response(data).into_response() -} - -pub fn stream_json_or_bin_response

( - accept: Option<&str>, - data: Box + Send>, -) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, -{ - if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { - return stream_bin_array(data); - } - stream_json_array(data) -} - -pub fn stream_json_or_bin_response_stream(accept: Option<&str>, data: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream + Send + Unpin + 'static, -{ - if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { - return stream_bin_array_stream(data); - } - stream_json_array_stream(data) -} - -pub fn stream_json_or_bin_response_try_stream(accept: Option<&str>, data: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream> + Send + Unpin + 'static, - E: std::fmt::Display + Send + 'static, -{ - if accept.is_some_and(|value| value.contains(CONTENT_TYPE_CBOR)) { - return stream_bin_array_try_stream(data); - } - stream_json_array_try_stream(data) -} - -pub fn create_session_fingerprint( - fingerprint: &Fingerprint, - username: &str, - virtual_id: u32, - socket_bound: bool, -) -> String { - if socket_bound { - concat_string!(&fingerprint.addr.to_string(), "|", username, "|", &virtual_id.to_string()) - } else { - concat_string!(&fingerprint.key, "|", username, "|", &virtual_id.to_string()) - } -} - -pub(crate) fn create_playback_session_fingerprint( - fingerprint: &Fingerprint, - username: &str, - virtual_id: u32, - item_type: PlaylistItemType, - extension: Option<&str>, -) -> String { - // This scopes the session identity, not the session address-tracking policy. - // 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 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) -} - -pub fn create_catchup_session_key(fingerprint: &Fingerprint, username: &str, virtual_id: u32) -> String { - concat_string!("catchup|", &fingerprint.key, "|", username, "|", &virtual_id.to_string(), "|session") -} - -pub fn create_m3u_catchup_session_key( - fingerprint: &Fingerprint, - username: &str, - virtual_id: u32, - archive_discriminator: &str, -) -> String { - concat_string!( - "m3u-catchup|", - &fingerprint.key, - "|", - username, - "|", - &virtual_id.to_string(), - "|", - archive_discriminator - ) -} - -pub(crate) fn is_session_based_playback(item_type: PlaylistItemType, extension: Option<&str>) -> bool { - item_type.is_live_adaptive() || matches!(extension, Some(ext) if ext == HLS_EXT || ext == DASH_EXT) -} - -pub(crate) fn is_socket_bound_playback_session(item_type: PlaylistItemType, extension: Option<&str>) -> bool { - item_type.uses_socket_bound_session() && !is_session_based_playback(item_type, extension) -} - -fn session_reacquire_cleanup_addrs(user_session: &UserSession, current_addr: &SocketAddr) -> Vec { - let mut addrs: SmallVec<[SocketAddr; 4]> = SmallVec::new(); - if user_session.addr != *current_addr { - addrs.push(user_session.addr); - } - for addr in &user_session.active_addrs { - if *addr != *current_addr && !addrs.contains(addr) { - addrs.push(*addr); - } - } - addrs.into_vec() -} - -pub(crate) fn should_allow_exhausted_shared_reconnect( - share_stream: bool, - user_session: Option<&UserSession>, - requested_virtual_id: u32, - requested_stream_url: &str, -) -> bool { - share_stream - && user_session.is_some_and(|session| { - session.permission != UserConnectionPermission::Exhausted - && session.virtual_id == requested_virtual_id - && session.stream_url.as_ref() == requested_stream_url - }) -} - -pub fn stream_json_array

(iter: Box + Send>) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, -{ - let stream = stream::unfold((iter, true), |(mut iter, first)| async move { - match iter.next() { - Some(item) => { - let mut json = String::new(); - if !first { - json.push(','); - } - let element = serde_json::to_string(&item).ok()?; - json.push_str(&element); - Some((Ok::(Bytes::from(json)), (iter, false))) - } - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) - .chain(stream) - .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"]")) })), - )); - - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) -} - -pub fn stream_bin_array

(iter: Box + Send>) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, -{ - let stream = stream::unfold(iter, |mut iter| async move { - match iter.next() { - Some(item) => { - match bin_serialize(&item) { - Ok(buf) => Some((Ok::(Bytes::from(buf)), iter)), - Err(err) => { - warn!("CBOR serialization error in stream: {err}"); - Some((Ok::(Bytes::new()), iter)) // skip errors, continue - } - } - } - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { - // CBOR: start indefinite-length array - Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) - }) - .chain(stream) - .chain(stream::once(async { - // CBOR: end indefinite-length array - Ok::<_, Infallible>(Bytes::from_static(&[0xff])) - })), - )); - - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) -} - -pub fn stream_json_array_stream(stream: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream + Send + Unpin + 'static, -{ - let stream = stream::unfold((stream, true), |(mut stream, first)| async move { - match stream.next().await { - Some(item) => { - let mut json = String::new(); - if !first { - json.push(','); - } - let element = serde_json::to_string(&item).ok()?; - json.push_str(&element); - Some((Ok::(Bytes::from(json)), (stream, false))) - } - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) - .chain(stream) - .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"]")) })), - )); - - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) -} - -fn stream_json_array_try_stream(stream: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream> + Send + Unpin + 'static, - E: std::fmt::Display + Send + 'static, -{ - let stream = stream::unfold((stream, true, false), |(mut stream, first, failed)| async move { - if failed { - return None; - } - match stream.next().await { - Some(Ok(item)) => { - let serialized = serde_json::to_vec(&item).map_err(|error| error.to_string()); - let bytes = serialized.map(|serialized| { - if first { - Bytes::from(serialized) - } else { - let mut framed = Vec::with_capacity(serialized.len() + 1); - framed.push(b','); - framed.extend_from_slice(&serialized); - Bytes::from(framed) - } - }); - let failed = bytes.is_err(); - Some((bytes, (stream, false, failed))) - } - Some(Err(error)) => Some((Err(error.to_string()), (stream, first, true))), - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { Ok::<_, String>(Bytes::from_static(b"[")) }) - .chain(stream) - .chain(stream::once(async { Ok::<_, String>(Bytes::from_static(b"]")) })), - )); - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) -} - -pub fn stream_bin_array_stream(stream: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream + Send + Unpin + 'static, -{ - let stream = stream::unfold(stream, |mut stream| async move { - match stream.next().await { - Some(item) => match bin_serialize(&item) { - Ok(buf) => Some((Ok::(Bytes::from(buf)), stream)), - Err(err) => { - warn!("CBOR serialization error in stream: {err}"); - Some((Ok::(Bytes::new()), stream)) - } - }, - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) }) - .chain(stream) - .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(&[0xff])) })), - )); - - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) -} - -fn stream_bin_array_try_stream(stream: S) -> axum::response::Response -where - P: serde::Serialize + Send + 'static, - S: Stream> + Send + Unpin + 'static, - E: std::fmt::Display + Send + 'static, -{ - let stream = stream::unfold((stream, false), |(mut stream, failed)| async move { - if failed { - return None; - } - match stream.next().await { - Some(Ok(item)) => { - let bytes = bin_serialize(&item).map(Bytes::from).map_err(|error| error.to_string()); - let failed = bytes.is_err(); - Some((bytes, (stream, failed))) - } - Some(Err(error)) => Some((Err(error.to_string()), (stream, true))), - None => None, - } - }); - - let body = Body::from_stream(coalesce_byte_stream( - stream::once(async { Ok::<_, String>(Bytes::from_static(&[0x9f])) }) - .chain(stream) - .chain(stream::once(async { Ok::<_, String>(Bytes::from_static(&[0xff])) })), - )); - try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) -} - -const API_STREAM_CHUNK_SIZE: usize = 64 * 1024; - -pub(crate) fn coalesce_byte_stream(stream: S) -> impl Stream> -where - S: Stream> + Send + 'static, - E: Send + 'static, -{ - stream::unfold((Box::pin(stream), None, false), |(mut stream, pending_error, finished)| async move { - if let Some(error) = pending_error { - return Some((Err(error), (stream, None, true))); - } - if finished { - return None; - } - - let mut chunk = BytesMut::with_capacity(API_STREAM_CHUNK_SIZE); - loop { - match stream.next().await { - Some(Ok(bytes)) if chunk.is_empty() && bytes.len() >= API_STREAM_CHUNK_SIZE => { - return Some((Ok(bytes), (stream, None, false))); - } - Some(Ok(bytes)) => { - chunk.extend_from_slice(&bytes); - if chunk.len() >= API_STREAM_CHUNK_SIZE { - return Some((Ok(chunk.freeze()), (stream, None, false))); - } - } - Some(Err(error)) if chunk.is_empty() => { - return Some((Err(error), (stream, None, true))); - } - Some(Err(error)) => { - return Some((Ok(chunk.freeze()), (stream, Some(error), false))); - } - None if chunk.is_empty() => return None, - None => return Some((Ok(chunk.freeze()), (stream, None, true))), - } - } - }) - .fuse() -} - -pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials { - let config = app_state.app_config.config.load(); - - let server = config - .web_ui - .as_ref() - .and_then(|web_ui| web_ui.player_server.as_ref()) - .map_or("default", |server_name| server_name.as_str()); - - ProxyUserCredentials { - username: "api_user".to_string(), - password: "api_user".to_string(), - token: None, - proxy: ProxyType::Reverse(None), - server: Some(server.to_string()), - epg_timeshift: None, - epg_request_timeshift: None, - created_at: None, - exp_date: None, - max_connections: 0, - status: None, - output_clusters: shared::model::ClusterFlags::all(), - ui_enabled: false, - comment: None, - priority: 0, - soft_connections: 0, - soft_priority: 0, - t_is_api_user: true, - network_access: None, - plan: None, - filter: None, - raw_output_clusters: None, - raw_max_connections: 0, - raw_soft_connections: 0, - raw_proxy: Some(ProxyType::Reverse(None)), - t_filter: None, - t_has_unresolved_plan: false, - t_has_invalid_filter: false, - } -} - -pub fn empty_json_response_as_object() -> axum::http::Result { - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) - .body(axum::body::Body::from("{}".as_bytes())) -} - -pub fn empty_json_response_as_array() -> axum::http::Result { - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) - .body(axum::body::Body::from("[]".as_bytes())) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - api::model::{ - ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, EventManager, - MetadataUpdateManager, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, - ProviderConfigConnection, SharedStreamManager, - }, - auth::Fingerprint, - model::{ - AppConfig, Config, ConfigInput, ConfigInputAlias, ConfigProvider, ConfigTarget, GracePeriodOptions, - MediaToolCapabilities, NetworkAccess, ProcessTargets, ProxyUserCredentials, SourcesConfig, - StreamHistoryConfig, - }, - repository::GeoIp, - utils::FileLockManager, - }; - use arc_swap::{ArcSwap, ArcSwapOption}; - use axum::http::{HeaderMap, Response, StatusCode}; - use bytes::Bytes; - use futures::stream; - use http_body_util::BodyExt; - use shared::{ - defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs}, - foundation::Filter, - model::{ - AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigProviderDto, ConfigTargetOptions, - GeoIpUnavailablePolicy, InputFetchMethod, InputType, PlaylistItemType, ProcessingOrder, - ProviderUrlSelectionPolicy, ProxyType, StreamChannel, XtreamCluster, - }, - utils::Internable, - }; - use std::{borrow::Cow, collections::HashMap, net::SocketAddr, sync::Arc}; - use tokio::{ - io::AsyncWriteExt, - net::TcpListener, - sync::{mpsc, RwLock}, - }; - use tuliprox_core::utils::response_compression::should_compress_response; - use tuliprox_session::{ - admission::{ - evaluate_remaining_strategies_after_grace, get_effective_admission_strategies, - RECENT_EVICTION_REENTRY_TTL_SECS, - }, - GraceResolutionContext, - }; - - #[test] - fn stalker_playback_refreshes_invalid_or_rejected_urls() { - assert!(should_refresh_stalker_playback(InputType::Stalker, false, None)); - assert!(should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::UNAUTHORIZED))); - assert!(!should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::OK))); - assert!(!should_refresh_stalker_playback(InputType::Xtream, false, None)); - } - - #[test] - fn initial_stalker_playback_resolves_only_empty_urls() { - assert!(needs_initial_stalker_resolution(InputType::Stalker, "")); - assert!(!needs_initial_stalker_resolution(InputType::Stalker, "https://stream.example/live.ts")); - assert!(!needs_initial_stalker_resolution(InputType::Xtream, "")); - assert_eq!(stalker_stream_kind(XtreamCluster::Live, PlaylistItemType::Catchup), StalkerStreamKind::Archive); - } - - fn test_runtime_provider(url: &str, username: &str, password: &str) -> Arc { - test_runtime_provider_with_type(url, username, password, InputType::Xtream) - } - - #[tokio::test] - async fn streamed_json_array_coalesces_small_entries() { - let response = stream_json_array_stream(stream::iter(0..4_096u32)); - let mut body = response.into_body(); - let mut frames = 0usize; - let mut bytes = Vec::new(); - while let Some(frame) = body.frame().await { - let Ok(frame) = frame else { - return; - }; - if let Ok(data) = frame.into_data() { - frames += 1; - bytes.extend_from_slice(&data); - } - } - assert!(frames <= 2, "small JSON entries should be coalesced, got {frames} frames"); - let decoded = serde_json::from_slice::>(&bytes); - assert!(decoded.is_ok_and(|values| values.len() == 4_096)); - } - - #[tokio::test] - async fn catchup_payload_probe_detects_fragmented_hls() { - let source = stream::iter([ - Ok::<_, StreamError>(Bytes::from_static(b"#EX")), - Ok(Bytes::from_static(b"TM3U\nsegment.ts\n")), - ]) - .boxed(); - - let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; - - assert!(matches!(&result, Ok(CatchupPayload::HlsManifest(_)))); - if let Ok(CatchupPayload::HlsManifest(manifest)) = result { - assert_eq!(manifest, b"#EXTM3U\nsegment.ts\n".as_slice()); - } - } - - #[tokio::test] - async fn catchup_payload_probe_replays_ts_bytes() { - let expected = Bytes::from_static(b"\x47direct-ts-payload"); - let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed(); - - let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; - - assert!(matches!(&result, Ok(CatchupPayload::Direct(_)))); - if let Ok(CatchupPayload::Direct(mut stream)) = result { - let mut actual = Vec::new(); - while let Some(chunk) = stream.next().await { - if let Ok(chunk) = chunk { - actual.extend_from_slice(&chunk); - } - } - assert_eq!(actual, expected.as_ref()); - } - } - - #[tokio::test] - async fn catchup_payload_probe_replays_partial_signature_at_eof() { - let expected = Bytes::from_static(b"#EXT"); - let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed(); - - let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; - - assert!(matches!(&result, Ok(CatchupPayload::Direct(_)))); - if let Ok(CatchupPayload::Direct(mut stream)) = result { - let actual = stream.next().await.and_then(Result::ok); - assert_eq!(actual.as_ref(), Some(&expected)); - assert!(stream.next().await.is_none()); - } - } - - #[tokio::test] - async fn catchup_payload_probe_rejects_oversized_manifest() { - let oversized = vec![b'x'; MAX_HLS_MANIFEST_BYTES]; - let source = - stream::iter([Ok::<_, StreamError>(Bytes::from_static(b"#EXTM3U")), Ok(Bytes::from(oversized))]).boxed(); - - let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn coalesced_stream_remains_finished_when_polled_again() { - let stream = coalesce_byte_stream(stream::empty::>()); - futures::pin_mut!(stream); - - assert!(stream.next().await.is_none()); - assert!(stream.next().await.is_none()); - } - - fn test_runtime_provider_with_type( - url: &str, - username: &str, - password: &str, - input_type: InputType, - ) -> Arc { - let url = if input_type == InputType::M3u { - format!("{url}/playlist.m3u8?username={username}&password={password}") - } else { - url.to_string() - }; - let input = ConfigInput { - name: "provider".intern(), - url, - username: Some(username.to_string()), - password: Some(password.to_string()), - input_type, - ..ConfigInput::default() - }; - Arc::new(RuntimeProviderConfig::new( - &input, - Arc::new(RwLock::new(ProviderConfigConnection::default())), - Arc::new(|_, _| {}), - )) - } - - fn test_runtime_provider_without_credentials(url: &str, input_type: InputType) -> Arc { - let input = - ConfigInput { name: "provider".intern(), url: url.to_string(), input_type, ..ConfigInput::default() }; - Arc::new(RuntimeProviderConfig::new( - &input, - Arc::new(RwLock::new(ProviderConfigConnection::default())), - Arc::new(|_, _| {}), - )) - } - - #[test] - fn test_is_seek_request() { - let mut headers = HeaderMap::new(); - - // No range header - assert!(!is_seek_request(XtreamCluster::Video, &headers)); - - // Range: bytes=0- (Should be true now to allow session takeover on restart) - headers.insert("range", "bytes=0-".parse().unwrap()); - assert!(is_seek_request(XtreamCluster::Video, &headers)); - - // Range: bytes=100- (Should be true) - headers.insert("range", "bytes=100-".parse().unwrap()); - assert!(is_seek_request(XtreamCluster::Video, &headers)); - - // Range: bytes=100-200 (Should be true) - headers.insert("range", "bytes=100-200".parse().unwrap()); - assert!(is_seek_request(XtreamCluster::Video, &headers)); - - // Live cluster should always return false - headers.insert("range", "bytes=100-".parse().unwrap()); - assert!(!is_seek_request(XtreamCluster::Live, &headers)); - } - - #[test] - fn hls_manifests_are_not_forced_as_seek_responses() { - let mut headers = HeaderMap::new(); - headers.insert("range", HeaderValue::from_static("bytes=0-")); - - assert!(!is_seekable_media_request(XtreamCluster::Video, &headers, Some(HLS_EXT))); - assert!(is_seekable_media_request(XtreamCluster::Video, &headers, Some(".ts"))); - } - - #[test] - fn resolve_redirect_location_resolves_provider_scheme_urls() { - let provider = ConfigProvider::from(&ConfigProviderDto { - name: "develop".intern(), - urls: vec!["https://provider.example".intern()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - }); - let input = ConfigInput { - name: "provider".intern(), - provider_configs: Some(vec![Arc::new(provider)]), - ..ConfigInput::default() - }; - - let resolved = - resolve_redirect_location(Some(&input), "provider://develop/live/provider-user/provider-pass/33486.m3u8") - .expect("provider url should resolve"); - - assert_eq!(resolved, "https://provider.example/live/provider-user/provider-pass/33486.m3u8"); - } - - #[test] - fn stream_alternative_url_keeps_unmatched_urls_unchanged() { - let input = ConfigInput { - name: "source".intern(), - url: "http://source.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://other.example/live/source-user/source-pass/123.ts"; - - let rewritten = get_stream_alternative_url(stream_url, &input, &alias); - - assert_eq!(rewritten, None); - } - - #[test] - fn stream_alternative_url_rewrites_only_query_auth_fields() { - let input = ConfigInput { - name: "source".intern(), - url: "http://source.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://source.example/player?token=source-user&username=source-user&password=source-pass"; - - let rewritten = get_stream_alternative_url(stream_url, &input, &alias); - - assert_eq!( - rewritten, - Some("http://alias.example/player?token=source-user&username=alias-user&password=alias-pass".to_string()) - ); - } - - #[test] - fn stream_url_matches_provider_requires_base_url_and_account_identity() { - let provider = test_runtime_provider("http://same.example", "selected-user", "selected-pass"); - - assert!(stream_url_matches_provider("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] - fn stream_url_matches_provider_accepts_external_playlist_url_for_m3u_without_account_signature() { - let provider = test_runtime_provider_with_type( - "http://provider.example", - "selected-user", - "selected-pass", - InputType::M3u, - ); - - assert!(stream_url_matches_provider( - "https://hlspackager.akamaized.net/live/DB/ALYAUM_TV/HLS/ALYAUM_TV.m3u8", - &provider - )); - assert!(stream_url_matches_provider( - "https://shd-gcp-live.edgenextcdn.net/live/bitmovin-mbc-1/15cf99af5de54063fdabfefe66adc075/index.m3u8", - &provider - )); - } - - #[test] - fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature() { - let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass"); - - assert!(!stream_url_matches_provider("http://cdn.example/live/other-user/other-pass/123.ts", &provider)); - assert!(!stream_url_matches_provider( - "http://cdn.example/segment.ts?username=other-user&password=other-pass", - &provider - )); - } - - #[test] - fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature_for_m3u() { - let provider = test_runtime_provider_with_type( - "http://provider.example", - "selected-user", - "selected-pass", - InputType::M3u, - ); - - assert!(!stream_url_matches_provider( - "http://cdn.example/segment.ts?username=other-user&password=other-pass", - &provider - )); - } - - #[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"); - - assert!(!stream_url_matches_provider("http://cdn.example/open/playlist.m3u8", &provider)); - assert!(!stream_url_matches_provider("http://cdn.example/open/segment.ts?key=signedopaque", &provider)); - } - - #[test] - fn stream_url_matches_provider_rejects_external_cdn_url_for_xtream_even_with_valid_account_signature() { - let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass"); - - assert!(!stream_url_matches_provider("http://cdn.example/live/selected-user/selected-pass/123.ts", &provider)); - assert!(!stream_url_matches_provider( - "http://cdn.example/segment.ts?username=selected-user&password=selected-pass", - &provider - )); - } - - #[test] - fn find_input_account_by_signature_matches_main_input_and_alias_accounts() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example".to_string(), - username: Some("main-user".to_string()), - password: Some("main-pass".to_string()), - input_type: InputType::Xtream, - aliases: Some(vec![ConfigInputAlias { - id: 2, - name: "alias".intern(), - url: "http://alias.example".to_string(), - username: Some("alias-user".to_string()), - password: Some("alias-pass".to_string()), - max_connections: 1, - priority: 0, - exp_date: None, - enabled: true, - stalker: None, - }]), - ..ConfigInput::default() - }; - - let main = find_input_account_by_signature("http://cdn.example/live/main-user/main-pass/1.ts", &input); - assert_eq!( - main, - Some( - ("http://provider.example".to_string(), Some("main-user".to_string()), Some("main-pass".to_string()),) - ) - ); - - let alias = find_input_account_by_signature("http://cdn.example/live/alias-user/alias-pass/1.ts", &input); - assert_eq!( - alias, - Some(("http://alias.example".to_string(), Some("alias-user".to_string()), Some("alias-pass".to_string()),)) - ); - - assert_eq!(find_input_account_by_signature("http://cdn.example/live/other/other/1.ts", &input), None); - assert_eq!(find_input_account_by_signature("http://cdn.example/open/playlist.m3u8", &input), None); - } - - #[test] - fn get_stream_alternative_url_rewrites_external_cdn_url_with_valid_account_signature_for_alias_account() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); - let stream_url = "http://cdn.example/live/source-user/source-pass/123.ts"; - - let rewritten = get_stream_alternative_url(stream_url, &input, &alias); - assert_eq!(rewritten, Some("http://cdn.example/live/alias-user/alias-pass/123.ts".to_string())); - } - - #[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 { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); - let stream_url = "https://cnbc-live.akamaized.net/cnbc/master.m3u8"; - - assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string())); - } - - #[test] - fn get_stream_alternative_url_keeps_open_external_multisegment_playlist_url_for_m3u() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); - let stream_url = "https://hnpsechtsc.turknet.ercdn.net/xpnvudnlsv/cnbc-e/cnbc-e.m3u8"; - - assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string())); - } - - #[test] - fn get_stream_alternative_url_keeps_open_external_m3u_url_for_provider_without_credentials() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8".to_string(), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let provider = - test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); - let stream_url = "http://s.only4.tv/17113/video.m3u8?token=abc"; - - assert_eq!(get_stream_alternative_url(stream_url, &input, &provider), Some(stream_url.to_string())); - } - - #[test] - fn get_stream_alternative_url_rejects_query_credentials_for_provider_without_credentials() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8".to_string(), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let provider = - test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); - - assert_eq!( - get_stream_alternative_url( - "http://cdn.example/segment.ts?username=other-user&password=other-pass", - &input, - &provider - ), - None - ); - } - - #[test] - fn get_stream_alternative_url_rejects_basic_auth_credentials_for_provider_without_credentials() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8".to_string(), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let provider = - test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); - - assert_eq!(get_stream_alternative_url("http://user:pass@cdn.example/segment.ts", &input, &provider), None); - } - - #[test] - fn get_stream_alternative_url_rejects_external_m3u_url_with_unmatched_account_signature() { - let input = ConfigInput { - name: "source".intern(), - url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); - - assert_eq!( - get_stream_alternative_url( - "http://cdn.example/segment.ts?username=other-user&password=other-pass", - &input, - &alias, - ), - None - ); - } - - #[test] - fn get_stream_alternative_url_does_not_passthrough_arbitrary_open_external_url_for_xtream() { - 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://cdn.example/open/playlist.m3u8"; - - assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), None); - } - - #[test] - fn media_server_proxy_response_header_filter_drops_hop_by_hop_headers() { - for name in [ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "trailers", - "transfer-encoding", - "upgrade", - ] { - assert!(is_hop_by_hop_response_header(&HeaderName::from_static(name))); - } - assert!(!is_hop_by_hop_response_header(&header::CONTENT_TYPE)); - } - - #[test] - fn media_server_image_error_status_classifies_client_and_upstream_failures() { - let parse_error = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .detail("media server image URL is missing required path parts"); - assert_eq!(media_server_image_error_status(&parse_error), StatusCode::BAD_REQUEST); - - let not_found = MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) - .detail("plex media-server image URL is missing image_path"); - assert_eq!(media_server_image_error_status(¬_found), StatusCode::NOT_FOUND); - - let upstream = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) - .detail("media-server image request failed"); - assert_eq!(media_server_image_error_status(&upstream), StatusCode::BAD_GATEWAY); - } - - #[test] - fn media_server_playback_urls_are_proxy_only_redirect_guard_candidates() { - let plex_input = ConfigInput { input_type: InputType::Plex, ..ConfigInput::default() }; - let emby_input = ConfigInput { input_type: InputType::Emby, ..ConfigInput::default() }; - let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() }; - - assert!(is_media_server_playback_url( - &plex_input, - "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" - )); - assert!(is_media_server_playback_url( - &m3u_input, - "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" - )); - assert!(is_media_server_playback_url(&plex_input, "https://plex.example/stream.mkv")); - assert!(!is_media_server_playback_url(&emby_input, "https://emby.example/stream.mkv")); - assert!(!is_media_server_playback_url(&m3u_input, "https://provider.example/stream.mkv")); - assert!(!is_media_server_stream_ref_url("https://provider.example/stream.mkv")); - assert!(is_media_server_stream_ref_url( - "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" - )); - assert_eq!( - resolve_request_url_for_logging( - &plex_input, - "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" - ) - .as_ref(), - "media-server://" - ); - } - - #[test] - fn test_streaming_response_extension_disables_compression() { - let mut response = Response::new(()); - mark_response_as_uncompressed(&mut response); - - assert!(!should_compress_response(&response)); - } - - async fn spawn_legacy_hls_test_origin( - response_head: String, - response_body: Vec, - ) -> (SocketAddr, tokio::task::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); - let origin_addr = listener.local_addr().expect("test origin address"); - let origin_task = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.expect("test origin accepts request"); - let mut request = Vec::new(); - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let mut chunk = [0_u8; 1024]; - let read = socket.read(&mut chunk).await.expect("test origin reads request"); - if read == 0 { - break; - } - request.extend_from_slice(&chunk[..read]); - } - socket.write_all(response_head.as_bytes()).await.expect("test origin writes response headers"); - socket.write_all(&response_body).await.expect("test origin writes response body"); - String::from_utf8_lossy(&request).into_owned() - }); - (origin_addr, origin_task) - } - - async fn forced_legacy_hls_test_response( - origin_addr: SocketAddr, - request_headers: &HeaderMap, - client_port: u16, - ) -> axum::response::Response { - let origin_url = format!("http://{origin_addr}/segment.ts"); - let input = Arc::new(ConfigInput { - id: 1, - name: "provider_1".intern(), - input_type: InputType::Xtream, - headers: HashMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]), - url: format!("http://{origin_addr}"), - enabled: true, - priority: 0, - max_connections: 1, - method: InputFetchMethod::default(), - ..ConfigInput::default() - }); - let app_config = create_test_provider_app_config(); - app_config - .sources - .store(Arc::new(SourcesConfig { inputs: vec![Arc::clone(&input)], ..SourcesConfig::default() })); - let app_state = create_test_app_state_for_config(Arc::new(app_config)); - let client_addr = SocketAddr::from(([127, 0, 0, 1], client_port)); - let fingerprint = create_test_fingerprint(client_addr); - let mut user = ProxyUserCredentials::default(); - user.username = "viewer".to_string(); - let session = UserSession { - token: format!("legacy-hls-marker-{client_port}"), - transition_version: 1, - virtual_id: 41, - provider: Arc::clone(&input.name), - stream_url: origin_url.as_str().intern(), - provider_session_headers: HashMap::new(), - addr: client_addr, - socket_bound: false, - active_addrs: vec![client_addr], - ts: 1, - started_at: 1, - permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - lifecycle: crate::api::model::PlaybackLifecycle::Active, - }; - let mut stream_channel = create_test_local_channel(&origin_url); - stream_channel.provider_id = u32::from(input.id); - stream_channel.input_name = Arc::clone(&input.name); - stream_channel.item_type = PlaylistItemType::Catchup; - stream_channel.cluster = XtreamCluster::Live; - stream_channel.url = origin_url.as_str().intern(); - - force_provider_stream_response( - &fingerprint, - &app_state, - &session, - stream_channel, - ForceStreamRequestContext { - req_headers: request_headers, - input: &input, - user: &user, - session_reservation_ttl_secs: 0, - content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, - }, - None, - ) - .await - .into_response() - } - - #[tokio::test] - async fn forced_hls_provider_response_disables_compression_and_streams_identity_bytes() { - const IDENTITY_BODY: &[u8] = b"legacy hls identity segment"; - - let mut encoder = async_compression::tokio::write::GzipEncoder::new(Vec::new()); - encoder.write_all(IDENTITY_BODY).await.expect("gzip test body encodes"); - encoder.shutdown().await.expect("gzip test encoder finishes"); - let encoded_body = encoder.into_inner(); - - let response_head = format!( - "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - encoded_body.len() - ); - let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, encoded_body).await; - let mut request_headers = HeaderMap::new(); - request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); - let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_310).await; - - assert_eq!(response.status(), StatusCode::OK); - assert!(!should_compress_response(&response)); - assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); - assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); - let body = response.into_body().collect().await.expect("legacy HLS response body").to_bytes(); - assert_eq!(body.as_ref(), IDENTITY_BODY); - - let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase(); - assert!(request.contains("\r\naccept-encoding: identity\r\n")); - } - - #[tokio::test] - async fn forced_hls_unencoded_partial_response_preserves_range_and_disables_compression() { - const PARTIAL_BODY: &[u8] = b"cdef"; - let response_head = format!( - "HTTP/1.1 206 Partial Content\r\nContent-Type: video/mp2t\r\nContent-Range: bytes 2-5/10\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - PARTIAL_BODY.len() - ); - let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, PARTIAL_BODY.to_vec()).await; - let mut request_headers = HeaderMap::new(); - request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); - request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=2-")); - - let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_311).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert!(!should_compress_response(&response)); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); - assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); - let body = response.into_body().collect().await.expect("legacy HLS partial body").to_bytes(); - assert_eq!(body.as_ref(), PARTIAL_BODY); - - let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase(); - assert!(request.contains("\r\naccept-encoding: identity\r\n")); - assert!(request.contains("\r\nrange: bytes=2-\r\n")); - } - - #[test] - fn test_regular_response_keeps_compression_enabled() { - let response = Response::new(()); - - assert!(should_compress_response(&response)); - } - - #[test] - fn test_get_stream_config_u64_uses_default_when_stream_config_missing() { - assert_eq!( - resolve_stream_config_u64(None, |stream| stream.hls_session_ttl_secs, default_hls_session_ttl_secs()), - default_hls_session_ttl_secs() - ); - assert_eq!( - resolve_stream_config_u64( - None, - |stream| stream.catchup_session_ttl_secs, - default_catchup_session_ttl_secs() - ), - default_catchup_session_ttl_secs() - ); - } - - #[tokio::test] - async fn test_get_session_reservation_ttl_secs_uses_hls_ttl_for_live_dash() { - let app_state = create_test_app_state(); - assert_eq!( - get_session_reservation_ttl_secs(&app_state, PlaylistItemType::LiveDash), - default_hls_session_ttl_secs() - ); - } - - #[test] - fn provider_affinity_policy_matches_stream_types() { - assert!(!PlaylistItemType::Live.requires_provider_affinity()); - assert!(!PlaylistItemType::LiveUnknown.requires_provider_affinity()); - assert!(PlaylistItemType::LiveHls.requires_provider_affinity()); - assert!(PlaylistItemType::LiveDash.requires_provider_affinity()); - assert!(PlaylistItemType::Video.requires_provider_affinity()); - assert!(PlaylistItemType::Series.requires_provider_affinity()); - assert!(PlaylistItemType::Catchup.requires_provider_affinity()); - } - - #[tokio::test] - async fn resolve_streaming_strategy_honors_forced_provider_fallback_policy() { - 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 pinned_provider = "provider_1".intern(); - let busy_addr: SocketAddr = "127.0.0.1:55301".parse().unwrap_or_else(|_| unreachable!()); - let strict_addr: SocketAddr = "127.0.0.1:55302".parse().unwrap_or_else(|_| unreachable!()); - let fallback_addr: SocketAddr = "127.0.0.1:55303".parse().unwrap_or_else(|_| unreachable!()); - let stream_url = "http://provider-1.example/movie/user1/pass1/1.mkv"; - - let busy = app_state - .active_provider - .acquire_exact_connection_with_grace( - &pinned_provider, - &busy_addr, - false, - 0, - crate::api::model::ConnectionKind::Normal, - ) - .await; - assert!(busy.is_some(), "setup should occupy the pinned provider"); - - let strict = resolve_streaming_strategy( - &app_state, - stream_url, - &create_test_fingerprint(strict_addr), - &input, - StreamingAcquireOptions { - force_provider: Some(&pinned_provider), - allow_forced_provider_fallback: false, - allow_provider_grace: false, - user_priority: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - session_owner: Some("vod-session"), - accept_requested_stream_url: false, - }, - ) - .await; - assert!(strict.provider_handle.is_none(), "strict provider affinity should not allocate a different provider"); - assert!( - matches!( - strict.provider_stream_state, - ProviderStreamState::Custom { reason: ProviderStreamCustomReason::ProviderExhausted, .. } - ), - "strict provider affinity should fail closed when the pinned provider is unavailable" - ); - - let fallback = resolve_streaming_strategy( - &app_state, - stream_url, - &create_test_fingerprint(fallback_addr), - &input, - StreamingAcquireOptions { - force_provider: Some(&pinned_provider), - allow_forced_provider_fallback: true, - allow_provider_grace: false, - user_priority: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - session_owner: Some("live-session"), - accept_requested_stream_url: false, - }, - ) - .await; - let (ProviderStreamState::Available(Some(fallback_provider), _) - | ProviderStreamState::GracePeriod(Some(fallback_provider), _)) = fallback.provider_stream_state - else { - panic!("fallback-enabled request should allocate a provider") - }; - assert_eq!(fallback_provider.as_ref(), "provider_2"); - - app_state.active_provider.release_connection(&busy_addr).await; - app_state.active_provider.release_connection(&strict_addr).await; - app_state.active_provider.release_connection(&fallback_addr).await; - } - - #[tokio::test] - async fn resolve_streaming_strategy_rewrites_stale_alias_url_to_selected_main_provider() { - 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:55304".parse().unwrap_or_else(|_| unreachable!()); - - let strategy = resolve_streaming_strategy( - &app_state, - "http://provider-2.example/live/user2/pass2/100.ts", - &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: false, - }, - ) - .await; - - let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else { - panic!("request should allocate the main provider") - }; - assert_eq!(provider.as_ref(), "provider_1"); - assert_eq!(url.as_ref(), "http://provider-1.example/live/user1/pass1/100.ts"); - - app_state.active_provider.release_connection(&addr).await; - } - - #[tokio::test] - async fn resolve_streaming_strategy_rejects_unmapped_provider_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:55305".parse().unwrap_or_else(|_| unreachable!()); - - let strategy = resolve_streaming_strategy( - &app_state, - "http://unmapped.example/live/user1/pass1/100.ts", - &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: false, - }, - ) - .await; - - assert!(strategy.provider_handle.is_none()); - assert!(matches!( - strategy.provider_stream_state, - ProviderStreamState::Custom { reason: ProviderStreamCustomReason::UnmappedProviderUrl, .. } - )); - - app_state.active_provider.release_connection(&addr).await; - } - - #[tokio::test] - async fn resolve_streaming_strategy_accepts_stalker_portal_url() { - let app_config = create_test_provider_app_config(); - let Some(configured_input) = app_config.sources.load().inputs.first().cloned() else { unreachable!() }; - let mut stalker_input = (*configured_input).clone(); - stalker_input.input_type = InputType::Stalker; - stalker_input.username = None; - stalker_input.password = None; - app_config - .sources - .store(Arc::new(SourcesConfig { inputs: vec![Arc::new(stalker_input)], ..SourcesConfig::default() })); - let app_state = create_test_app_state_for_config(Arc::new(app_config)); - 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:55307".parse().unwrap_or_else(|_| unreachable!()); - let stream_url = - "http://line.example/play/live.php?mac=00:11:22:33:44:55&stream=347&extension=ts&play_token=abc"; - - let strategy = resolve_streaming_strategy( - &app_state, - stream_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: false, - }, - ) - .await; - - let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else { - unreachable!() - }; - assert_eq!(provider.as_ref(), "provider_1"); - assert_eq!(url.as_ref(), stream_url); - - 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 { - transition_version: 1, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - token: "tok".to_string(), - virtual_id: 282, - provider: Arc::::from("provider"), - stream_url: Arc::::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!())], - ts: 1, - started_at: 1, - permission: UserConnectionPermission::Allowed, - lifecycle: crate::api::model::PlaybackLifecycle::Active, - }; - - assert!(should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/449924.ts")); - assert!(!should_allow_exhausted_shared_reconnect(false, Some(&session), 282, "http://provider/live/449924.ts")); - assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 999, "http://provider/live/449924.ts")); - assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/other.ts")); - } - - fn create_test_app_config() -> AppConfig { - let input = Arc::new(ConfigInput { - id: 1, - name: "local_media".intern(), - input_type: InputType::Library, - headers: HashMap::default(), - url: "file:///tmp".to_string(), - enabled: true, - priority: 0, - max_connections: 1, - method: InputFetchMethod::default(), - aliases: None, - ..ConfigInput::default() - }); - let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; - - AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config::default())), - sources: Arc::new(ArcSwap::from_pointee(sources)), - hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::default()), - file_locks: Arc::new(FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - })), - custom_stream_response: Arc::new(ArcSwapOption::default()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(MediaToolCapabilities::new()), - } - } - - fn create_test_provider_app_config() -> AppConfig { - let input = Arc::new(ConfigInput { - id: 1, - name: "provider_1".intern(), - input_type: InputType::Xtream, - headers: HashMap::default(), - url: "http://provider-1.example".to_string(), - username: Some("user1".to_string()), - password: Some("pass1".to_string()), - enabled: true, - priority: 0, - max_connections: 1, - method: InputFetchMethod::default(), - aliases: None, - ..ConfigInput::default() - }); - let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; - - AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config::default())), - sources: Arc::new(ArcSwap::from_pointee(sources)), - hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::default()), - file_locks: Arc::new(FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - })), - custom_stream_response: Arc::new(ArcSwapOption::default()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(MediaToolCapabilities::new()), - } - } - - fn create_test_dual_provider_app_config() -> AppConfig { - let input = Arc::new(ConfigInput { - id: 1, - name: "provider_1".intern(), - input_type: InputType::Xtream, - headers: HashMap::default(), - url: "http://provider-1.example".to_string(), - username: Some("user1".to_string()), - password: Some("pass1".to_string()), - enabled: true, - priority: 0, - max_connections: 1, - method: InputFetchMethod::default(), - aliases: Some(vec![ConfigInputAlias { - id: 2, - name: "provider_2".intern(), - url: "http://provider-2.example".to_string(), - username: Some("user2".to_string()), - password: Some("pass2".to_string()), - priority: 1, - max_connections: 1, - exp_date: None, - enabled: true, - stalker: None, - }]), - ..ConfigInput::default() - }); - let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; - - AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config::default())), - sources: Arc::new(ArcSwap::from_pointee(sources)), - hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::default()), - file_locks: Arc::new(FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - })), - custom_stream_response: Arc::new(ArcSwapOption::default()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(MediaToolCapabilities::new()), - } - } - - fn create_test_app_state() -> Arc { create_test_app_state_for_config(Arc::new(create_test_app_config())) } - - #[tokio::test] - async fn create_api_proxy_user_defaults_output_clusters_to_all() { - let app_state = create_test_app_state(); - let user = create_api_proxy_user(&app_state); - assert_eq!(user.output_clusters, ClusterFlags::all()); - } - - fn create_test_provider_app_state() -> Arc { - create_test_app_state_for_config(Arc::new(create_test_provider_app_config())) - } - - fn create_test_dual_provider_app_state() -> Arc { - create_test_app_state_for_config(Arc::new(create_test_dual_provider_app_config())) - } - - fn create_test_app_state_for_config(app_cfg: Arc) -> Arc { - let event_manager = Arc::new(EventManager::new()); - let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); - let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); - let history_config = Some(StreamHistoryConfig::default()); - active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); - - let geoip = Arc::new(ArcSwapOption::::default()); - let config = app_cfg.config.load(); - let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - let connection_manager = Arc::new(ConnectionManager::new( - &active_users, - &active_provider, - &shared_stream_manager, - &event_manager, - history_config.as_ref(), - )); - - let tokens = CancelTokens::default(); - let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); - let (manual_update_sender, _) = mpsc::channel::(1); - - Arc::new(AppState { - forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { - enabled: false, - inputs: Vec::new(), - targets: Vec::new(), - target_names: Vec::new(), - })), - app_config: app_cfg, - http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - downloads: Arc::new(crate::api::model::DownloadQueue::new()), - cache: Arc::new(ArcSwapOption::default()), - shared_stream_manager, - hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), - hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), - active_users, - active_provider, - connection_manager, - event_manager, - cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), - playlists: Arc::new(PlaylistStorageState::new()), - geoip, - update_guard: crate::api::model::UpdateGuard::new(), - metadata_manager, - identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( - std::path::PathBuf::new(), - )), - login_throttle: Arc::new(crate::auth::LoginThrottle::new()), - token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( - std::path::PathBuf::new(), - )), - manual_update_sender, - }) - } - - fn create_test_fingerprint(addr: std::net::SocketAddr) -> Fingerprint { - Fingerprint::new(format!("fp-{addr}"), addr.ip().to_string(), addr) - } - - fn create_test_fingerprint_with_user_agent(addr: std::net::SocketAddr, user_agent: &str) -> Fingerprint { - Fingerprint::new(format!("{}|{user_agent}", addr.ip()), addr.ip().to_string(), addr) - } - - fn create_test_app_state_with_stream_config(stream: crate::model::StreamConfig) -> Arc { - let config = Config { - reverse_proxy: Some(crate::model::ReverseProxyConfig { - resource_rewrite_disabled: false, - rewrite_secret: [0; 16], - resource_retry: crate::model::ResourceRetryConfig::default(), - disabled_header: None, - stream: Some(stream), - cache: None, - rate_limit: None, - geoip: None, - stream_history: None, - qos_aggregation: None, - hls_cache: None, - }), - user_access_control: true, - ..Config::default() - }; - - let mut app_cfg = create_test_app_config(); - app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); - create_test_app_state_for_config(Arc::new(app_cfg)) - } - - fn create_test_local_channel(url: &str) -> StreamChannel { - StreamChannel { - target_id: 1, - virtual_id: 41, - provider_id: 0, - input_name: "library".intern(), - item_type: PlaylistItemType::LocalVideo, - cluster: XtreamCluster::Video, - group: "Local Movies".intern(), - title: "Local Test".intern(), - url: url.into(), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - } - } - - fn create_test_live_channel(url: &str) -> StreamChannel { - StreamChannel { - target_id: 1, - virtual_id: 42, - provider_id: 1, - input_name: "provider_1".intern(), - item_type: PlaylistItemType::Live, - cluster: XtreamCluster::Live, - group: "Live".intern(), - title: "Shared Live".intern(), - url: url.into(), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - } - } - - fn create_test_session( - token: &str, - item_type: PlaylistItemType, - lifecycle: crate::api::model::PlaybackLifecycle, - ) -> UserSession { - UserSession { - token: token.to_string(), - transition_version: 1, - virtual_id: 42, - provider: Arc::::from("provider-a"), - stream_url: Arc::::from(match item_type { - 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(), - ts: 1, - started_at: 1, - permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - lifecycle, - } - } - - #[test] - fn classify_playback_request_marks_adaptive_playlist_request_as_prepare() { - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: None, - prepare_only: true, - terminate: false, - }); - - assert_eq!(request_class, PlaybackRequestClass::Prepare); - } - - #[test] - fn classify_playback_request_marks_preserved_session_as_activate() { - let session = create_test_session( - "tok-preserved", - PlaylistItemType::LiveHls, - crate::api::model::PlaybackLifecycle::Preserved, - ); - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!(request_class, PlaybackRequestClass::Activate); - } - - #[test] - fn classify_playback_request_marks_counted_session_as_follow_up() { - let session = - create_test_session("tok-active", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Active); - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!(request_class, PlaybackRequestClass::FollowUp); - } - - /// `PendingProvider` must NOT be classified as `FollowUp`. - /// `PendingProvider` has no counted lease yet — the session is still waiting - /// for a provider slot. A new request on a `PendingProvider` session should - /// be `Activate` so that full admission evaluation happens, not a cheap - /// `FollowUp` skip. - #[test] - fn classify_playback_request_marks_pending_provider_as_activate_not_follow_up() { - let session = create_test_session( - "tok-pending", - PlaylistItemType::LiveHls, - crate::api::model::PlaybackLifecycle::PendingProvider { - data: crate::api::model::PendingProviderState { - reason_code: crate::api::model::PendingProviderReason::GraceHold, - created_at: 1, - deadline: 30, - version: 1, - wake_source: None, - }, - }, - ); - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!( - request_class, - PlaybackRequestClass::Activate, - "PendingProvider should not be FollowUp - it has no counted lease yet" - ); - } - - /// `Active` without a counted lease must NOT be classified as `FollowUp`. - /// `FollowUp` should only be returned when the session actually owns a - /// counted admission lease. A session with `Active` lifecycle but no counted - /// lease should go through `Activate` so that the counted lease is reacquired. - #[test] - fn classify_playback_request_marks_active_without_counted_as_activate_not_follow_up() { - let mut session = create_test_session( - "tok-active-uncounted", - PlaylistItemType::LiveHls, - crate::api::model::PlaybackLifecycle::Active, // counted=false via is_counted() - ); - // Manually force counted=false by setting to Prepared lifecycle, then restoring - // Note: is_counted() returns false for Prepared, true for Active - // For this test we need a session that is Active lifecycle but not counted - // The new model derives counted from lifecycle, so we must use a different lifecycle - // to represent "not counted". Use Prepared instead. - session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!( - request_class, - PlaybackRequestClass::Activate, - "Active session with counted=false should not be FollowUp" - ); - } - - /// Prepared sessions must be classified as Activate. - #[test] - fn classify_playback_request_marks_prepared_session_as_activate() { - let session = create_test_session( - "tok-prepared", - PlaylistItemType::LiveHls, - crate::api::model::PlaybackLifecycle::Prepared, - ); - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!(request_class, PlaybackRequestClass::Activate); - } - - /// `GraceActive` without counted lease must NOT be classified as `FollowUp`. - #[test] - fn classify_playback_request_marks_grace_active_without_counted_as_activate() { - let mut session = create_test_session( - "tok-grace-uncounted", - PlaylistItemType::LiveHls, - crate::api::model::PlaybackLifecycle::Active, // is_counted() = true for GraceActive - ); - // Test scenario: session has GraceActive lifecycle but we need it NOT counted - // This represents the edge case before grace task resolves. Use Prepared lifecycle - // to model "not counted" since is_counted() returns false for Prepared. - session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; - - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: Some(&session), - prepare_only: false, - terminate: false, - }); - - assert_eq!( - request_class, - PlaybackRequestClass::Activate, - "GraceActive session with counted=false should not be FollowUp" - ); - } - - #[tokio::test] - async fn activate_session_before_stream_open_skips_placeholder_for_follow_up_session() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), - }); - let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "follow-up-user".to_string(); - user.max_connections = 1; - let mut channel = create_test_live_channel("http://provider-1.example/live/55220.m3u8"); - channel.item_type = PlaylistItemType::LiveHls; - channel.virtual_id = 55220; - - app_state.connection_manager.add_connection(&addr).await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-follow-up", - virtual_id: channel.virtual_id, - provider: input.name.as_ref(), - stream_url: channel.url.as_ref(), - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 10, - fingerprint: &fingerprint, - provider: input.name.clone(), - stream_channel: &channel, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-follow-up"), - }) - .await; - - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-follow-up", - request_class: None, - virtual_id: VirtualId::new(channel.virtual_id), - item_type: PlaylistItemType::LiveHls, - stream_url: channel.url.as_ref(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: true, - }, - ) - .await; - - assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal)); - assert_eq!(activation.grace_mode, None); - assert!( - activation.placeholder_transition_version.is_none(), - "follow-up activation must not create a placeholder session" - ); - } - - #[tokio::test] - async fn activate_session_before_stream_open_revalidates_precomputed_follow_up_request_class() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), - }); - let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "precomputed-follow-up-user".to_string(); - user.max_connections = 1; - let mut channel = create_test_live_channel("http://provider-1.example/live/55221.m3u8"); - channel.item_type = PlaylistItemType::LiveHls; - channel.virtual_id = 55221; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-precomputed-follow-up", - virtual_id: channel.virtual_id, - provider: input.name.as_ref(), - stream_url: channel.url.as_ref(), - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-precomputed-follow-up", - request_class: Some(PlaybackRequestClass::FollowUp), - virtual_id: VirtualId::new(channel.virtual_id), - item_type: PlaylistItemType::LiveHls, - stream_url: channel.url.as_ref(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: true, - }, - ) - .await; - - assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal)); - assert_eq!(activation.grace_mode, None); - assert!( - activation.placeholder_transition_version.is_some(), - "precomputed FollowUp must be revalidated against the current uncounted lifecycle" - ); - } - - // stale FollowUp revalidation - #[tokio::test] - async fn activate_session_before_stream_open_stale_follow_up_reclassified_on_counted_lease_release() { - // Scenario: pre-computed FollowUp, but session's counted lease was released before - // the guard was acquired. Must reclassify to Activate so admission runs. - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), - }); - let addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "stale-followup-user".to_string(); - user.max_connections = 1; - let mut channel = create_test_live_channel("http://provider-1.example/live/55230.m3u8"); - channel.item_type = PlaylistItemType::LiveHls; - channel.virtual_id = 55230; - - // Session created in Active (counted) state. - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-stale-followup", - virtual_id: channel.virtual_id, - provider: input.name.as_ref(), - stream_url: channel.url.as_ref(), - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - // Simulate the counted lease being released before activation: - // expire the session so it no longer has a counted lease. - app_state.active_users.terminate_session(&user.username, "tok-stale-followup").await; - - // Call activate with stale FollowUp. Must NOT skip admission — reclassification - // to Activate must run so the placeholder is created. - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-stale-followup", - request_class: Some(PlaybackRequestClass::FollowUp), - virtual_id: VirtualId::new(channel.virtual_id), - item_type: PlaylistItemType::LiveHls, - stream_url: channel.url.as_ref(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: true, - }, - ) - .await; - - // Must NOT skip — placeholder must be created since session is expired. - assert!( - activation.placeholder_transition_version.is_some(), - "stale FollowUp with expired session must run admission and create placeholder" - ); - } - - // pre-resolved Grace materialization - #[tokio::test] - async fn activate_session_before_stream_open_pre_resolved_grace_period_materializes_pending_provider() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - let addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "pre-resolved-grace-user".to_string(); - user.max_connections = 1; - let mut channel = create_test_live_channel("http://provider-1.example/live/55231.m3u8"); - channel.item_type = PlaylistItemType::LiveHls; - channel.virtual_id = 55231; - - // Session in Prepared state (no grace lifecycle yet). - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-pre-resolved-grace", - virtual_id: channel.virtual_id, - provider: input.name.as_ref(), - stream_url: channel.url.as_ref(), - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - // Call activation with pre-resolved GracePeriod permission. - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-pre-resolved-grace", - request_class: None, - virtual_id: VirtualId::new(channel.virtual_id), - item_type: PlaylistItemType::LiveHls, - stream_url: channel.url.as_ref(), - connection_permission: UserConnectionPermission::GracePeriod, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: true, - }, - ) - .await; - - assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod); - assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold)); - - let session = - app_state.active_users.get_and_update_user_session(&user.username, "tok-pre-resolved-grace").await; - assert!( - session - .is_some_and(|s| matches!(s.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. })), - "pre-resolved GracePeriod must materialize as PendingProvider lifecycle" - ); - } - - /// `activate_session_before_stream_open` skips placeholder for Prepare class. - #[tokio::test] - async fn activate_session_before_stream_open_skips_placeholder_for_prepare() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), - }); - let addr: SocketAddr = "127.0.0.1:55222".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "prepare-user".to_string(); - user.max_connections = 1; - - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-prepare", - // Explicitly pass Prepare class — placeholder and admission should be skipped. - request_class: Some(PlaybackRequestClass::Prepare), - virtual_id: VirtualId::new(55222), - item_type: PlaylistItemType::LiveHls, - stream_url: "http://provider.example/live/test.ts", - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: true, - }, - ) - .await; - - // Prepare returns Allowed without running admission strategies. - assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(activation.grace_mode, None); - assert!( - activation.placeholder_transition_version.is_none(), - "Prepare activation must not create a placeholder session" - ); - } - - /// `resolve_playback_request_admission` with `prepare_only = true` returns `Prepare` class. - #[tokio::test] - async fn resolve_playback_request_admission_prepare_only_returns_prepare_class() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - let addr: SocketAddr = "127.0.0.1:55223".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let mut user = ProxyUserCredentials::default(); - user.username = "prepare-only-user".to_string(); - user.max_connections = 1; - - let (admission, grace_mode, request_class) = resolve_playback_request_admission( - &app_state.admission_ctx(), - &user, - &fingerprint, - None, - "tok-prepare-only", - false, - EvictionReentryGuard::Session("tok-prepare-only"), - true, // prepare_only - false, // terminate - ) - .await; - - assert_eq!(request_class, PlaybackRequestClass::Prepare); - // Prepare returns Allowed without running strategies. - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(grace_mode, None); - } - - /// `resolve_playback_request_admission` with `terminate = true` returns `Terminate` class - /// and calls `terminate_session` on the existing session. - #[tokio::test] - async fn resolve_playback_request_admission_terminate_returns_terminate_class() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - let addr: SocketAddr = "127.0.0.1:55224".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let mut user = ProxyUserCredentials::default(); - user.username = "terminate-user".to_string(); - user.max_connections = 2; - - // First create a session. - let session_token = "tok-terminate"; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token, - virtual_id: 55224, - provider: "test-provider", - stream_url: "http://provider.example/test.ts", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Verify session exists. - let before = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; - assert!(before.is_some(), "session should exist before terminate"); - - let (admission, grace_mode, request_class) = resolve_playback_request_admission( - &app_state.admission_ctx(), - &user, - &fingerprint, - before.as_ref(), - session_token, - false, - EvictionReentryGuard::Session(session_token), - false, // prepare_only - true, // terminate - ) - .await; - - assert_eq!(request_class, PlaybackRequestClass::Terminate); - assert_eq!(admission.permission, UserConnectionPermission::Exhausted); - assert_eq!(grace_mode, None); - - // Session should be expired after terminate. - let after = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; - assert!(after.is_none(), "session should be removed after terminate"); - } - - /// `classify_playback_request` returns `Terminate` when `terminate = true`. - #[test] - fn classify_playback_request_returns_terminate_when_flag_set() { - let request_class = classify_playback_request(PlaybackRequestFacts { - existing_session: None, - prepare_only: false, - terminate: true, - }); - assert_eq!(request_class, PlaybackRequestClass::Terminate); - } - - #[tokio::test] - async fn activate_session_before_stream_open_marks_pending_provider_for_grace_hold() { - let stream_cfg = crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }; - let mut app_cfg = create_test_app_config(); - app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { - user_access_control: true, - reverse_proxy: Some(crate::model::ReverseProxyConfig { - resource_rewrite_disabled: false, - rewrite_secret: [0; 16], - resource_retry: crate::model::ResourceRetryConfig::default(), - disabled_header: None, - stream: Some(stream_cfg), - cache: None, - rate_limit: None, - geoip: None, - stream_history: None, - qos_aggregation: None, - hls_cache: None, - }), - ..Config::default() - })); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); - let first_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let second_fingerprint = create_test_fingerprint(second_addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "grace-hold-user".to_string(); - user.max_connections = 1; - let first_channel = create_test_live_channel("http://provider-1.example/live/1.ts"); - let mut second_channel = create_test_live_channel("http://provider-1.example/live/2.m3u8"); - second_channel.item_type = PlaylistItemType::LiveHls; - second_channel.virtual_id = 55231; - - app_state.connection_manager.add_connection(&first_addr).await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 10, - fingerprint: &first_fingerprint, - provider: input.name.clone(), - stream_channel: &first_channel, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-first"), - }) - .await; - - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &second_fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-grace-hold", - request_class: None, - virtual_id: VirtualId::new(second_channel.virtual_id), - item_type: PlaylistItemType::LiveHls, - stream_url: second_channel.url.as_ref(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: false, - }, - ) - .await; - - assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod); - assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold)); - - let session = app_state - .active_users - .get_and_update_user_session(&user.username, "tok-grace-hold") - .await - .expect("placeholder session should exist"); - let crate::api::model::PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else { - panic!("grace hold should mark pending provider state") - }; - assert!(matches!(pending.reason_code, crate::api::model::PendingProviderReason::GraceHold)); - assert!(pending.deadline >= pending.created_at); - assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); - assert!( - !session.lifecycle.is_counted(), - "pending provider placeholder must not consume an active user lease before commit" - ); - } - - #[tokio::test] - async fn activate_session_before_stream_open_does_not_commit_user_lease_before_provider_success() { - let stream_cfg = crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 0, - grace_period_timeout_secs: 8, - grace_period_hold_stream: false, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: None, - }; - let mut app_cfg = create_test_app_config(); - app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { - user_access_control: true, - reverse_proxy: Some(crate::model::ReverseProxyConfig { - resource_rewrite_disabled: false, - rewrite_secret: [0; 16], - resource_retry: crate::model::ResourceRetryConfig::default(), - disabled_header: None, - stream: Some(stream_cfg), - cache: None, - rate_limit: None, - geoip: None, - stream_history: None, - qos_aggregation: None, - hls_cache: None, - }), - ..Config::default() - })); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); - let addr: SocketAddr = "127.0.0.1:55232".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input = app_state.app_config.sources.load().inputs[0].clone(); - let mut user = ProxyUserCredentials::default(); - user.username = "atomic-commit-user".to_string(); - user.max_connections = 1; - let channel = create_test_live_channel("http://provider-1.example/live/3.ts"); - - let activation = activate_session_before_stream_open( - &app_state, - SessionActivationRequest { - fingerprint: &fingerprint, - input: input.as_ref(), - user: &user, - session_token: "tok-atomic-commit", - request_class: None, - virtual_id: VirtualId::new(channel.virtual_id), - item_type: channel.item_type, - stream_url: channel.url.as_ref(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: crate::api::model::ConnectionKind::Normal, - socket_bound: false, - }, - ) - .await; - - assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(activation.grace_mode, None); - - let session = app_state - .active_users - .get_and_update_user_session(&user.username, "tok-atomic-commit") - .await - .expect("placeholder session should exist"); - assert_eq!( - app_state.active_users.user_connections(&user.username).await, - 0, - "allowed activation should stay provisional until provider acquisition and stream commit succeed" - ); - assert!( - !session.lifecycle.is_counted(), - "placeholder session must stay uncounted until the provider side has been committed" - ); - assert!(!matches!(session.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. })); - } - - fn create_test_shared_target() -> ConfigTarget { - ConfigTarget { - id: 1, - enabled: true, - name: "shared".to_string(), - options: Some(ConfigTargetOptions { - share_live_streams: shared::model::ConfigTargetShareLiveStreams { mpeg_ts: true, ..Default::default() }, - ..ConfigTargetOptions::default() - }), - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - } - } - - #[test] - fn admission_failure_reason_maps_to_custom_video_type() { - assert!(matches!( - admission_failure_video_type(ConnectFailureReason::UserAccountExpired), - Some(CustomVideoStreamType::UserAccountExpired) - )); - assert!(matches!( - admission_failure_video_type(ConnectFailureReason::UserConnectionsExhausted), - Some(CustomVideoStreamType::UserConnectionsExhausted) - )); - assert!(matches!( - admission_failure_video_type(ConnectFailureReason::ProviderConnectionsExhausted), - Some(CustomVideoStreamType::ProviderConnectionsExhausted) - )); - assert!(admission_failure_video_type(ConnectFailureReason::ProviderError).is_none()); - } - - #[tokio::test] - async fn effective_admission_strategies_use_legacy_grace_when_field_missing() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: None, - }); - - assert_eq!( - get_effective_admission_strategies(&app_state.admission_ctx()).as_ref(), - &[shared::model::AdmissionStrategy::GraceHoldStream][..] - ); - } - - #[tokio::test] - async fn effective_admission_strategies_respect_explicit_empty_list() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![]), - }); - - assert!(get_effective_admission_strategies(&app_state.admission_ctx()).is_empty()); - } - - #[tokio::test] - async fn grace_context_is_populated_when_grace_strategy_is_actually_granted() { - // Use a DIFFERENT session token than the pre-existing counted session. - // Otherwise session-admission may treat it as a valid reopen and skip the exhausted path. - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![ - AdmissionStrategy::EvictUserSameIpOldest, - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::EvictUserOldest, - ]), - }); - - let addr1: SocketAddr = "127.0.0.1:55401".parse().unwrap_or_else(|_| unreachable!()); - let addr2: SocketAddr = "10.0.0.5:55402".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint1 = create_test_fingerprint(addr1); - let fingerprint2 = create_test_fingerprint(addr2); - // addr1 and addr2 have DIFFERENT IPs. - // EvictUserSameIpOldest will NOT match (different IP), so GraceHoldStream is evaluated. - let mut user = ProxyUserCredentials::default(); - user.username = "user-grace-ctx".to_string(); - user.max_connections = 1; - - // Register the connection first so update_connection succeeds - app_state.connection_manager.add_connection(&addr1).await; - - // Create the session — lifecycle starts as Prepared (uncounted) - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-existing-counted", - virtual_id: 55401, - provider: "provider_1", - stream_url: "http://provider-1.example/live/55401.m3u8", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - // update_connection promotes the session to Active (counted) and creates a stream. - // This exhausts the user's single slot (max_connections = 1). - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 55401, - meter_uid: 55401, - username: "user-grace-ctx", - max_connections: 1, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint1, - provider: "provider_1".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/live/55401.m3u8"), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-existing-counted"), - }) - .await - .expect("stream should be created"); - - // Now the new request finds the slot exhausted and the grace strategy kicks in. - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &fingerprint2.client_ip, - request_addr: &fingerprint2.addr, - use_session_admission: true, - session_token: Some("tok-new-request"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-request"), - }, - ) - .await; - - assert_eq!(result.admission.permission, UserConnectionPermission::GracePeriod, "grace should be granted"); - assert!(matches!(result.grace_mode, Some(crate::api::model::GraceMode::Hold))); - let ctx = result.grace_context.expect("grace_context must be present when grace is granted"); - assert_eq!(ctx.strategy_index, 1, "GraceHoldStream is at index 1"); - assert_eq!(ctx.strategies.len(), 3); - assert!(matches!(ctx.strategies[ctx.strategy_index], AdmissionStrategy::GraceHoldStream)); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_evicts_after_used_grace() { - // Strategies: [GraceHoldStream, EvictUserOldest] - // Grace was used at index 0, so only EvictUserOldest (index 1) is evaluated. - // Eviction frees the slot -> Allowed. - let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), - }); - - let addr1: SocketAddr = "127.0.0.1:55701".parse().unwrap_or_else(|_| unreachable!()); - let addr2: SocketAddr = "10.0.0.5:55702".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint1 = create_test_fingerprint(addr1); - let fingerprint2 = create_test_fingerprint(addr2); - - app_state.connection_manager.add_connection(&addr1).await; - app_state.connection_manager.add_connection(&addr2).await; - - let mut user = ProxyUserCredentials::default(); - user.username = "remaining-evict".to_string(); - user.max_connections = 1; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-counted", - virtual_id: 55701, - provider: "provider-evict", - stream_url: "http://provider.example/live/1.ts", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 55701, - meter_uid: 55701, - username: "remaining-evict", - max_connections: 1, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint1, - provider: "provider-evict".intern(), - stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-counted"), - }) - .await - .expect("stream should be created"); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "remaining-evict", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint2.client_ip, - request_addr: &fingerprint2.addr, - use_session_admission: true, - session_token: Some("tok-new"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), - }, - &grace_context, - Some(crate::api::model::ConnectionKind::Normal), - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::Allowed, - "EvictUserOldest should free the slot" - ); - assert!(result.grace_context.is_none(), "no grace context on eviction success"); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_skips_no_match_and_uses_later_eviction() { - // Strategies: [GraceHoldStream, EvictUserSameIpOldest, EvictUserOldest] - // Grace was at index 0, remaining are EvictUserSameIpOldest (index 1) and EvictUserOldest (index 2). - // The existing counted session is at a DIFFERENT IP, so EvictUserSameIpOldest -> NoMatch. - // EvictUserOldest succeeds -> Allowed. - let strategies = vec![ - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::EvictUserSameIpOldest, - AdmissionStrategy::EvictUserOldest, - ]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![ - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::EvictUserSameIpOldest, - AdmissionStrategy::EvictUserOldest, - ]), - }); - - let addr1: SocketAddr = "127.0.0.1:55801".parse().unwrap_or_else(|_| unreachable!()); - let addr2: SocketAddr = "10.0.0.5:55802".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint1 = create_test_fingerprint(addr1); - let fingerprint2 = create_test_fingerprint(addr2); - - app_state.connection_manager.add_connection(&addr1).await; - app_state.connection_manager.add_connection(&addr2).await; - - let mut user = ProxyUserCredentials::default(); - user.username = "remaining-skip-no-match".to_string(); - user.max_connections = 1; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-counted", - virtual_id: 55801, - provider: "provider-skip", - stream_url: "http://provider.example/live/1.ts", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 55801, - meter_uid: 55801, - username: "remaining-skip-no-match", - max_connections: 1, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint1, - provider: "provider-skip".intern(), - stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-counted"), - }) - .await - .expect("stream should be created"); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "remaining-skip-no-match", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint2.client_ip, - request_addr: &fingerprint2.addr, - use_session_admission: true, - session_token: Some("tok-new"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), - }, - &grace_context, - Some(crate::api::model::ConnectionKind::Normal), - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::Allowed, - "EvictUserSameIpOldest should NoMatch, EvictUserOldest should succeed" - ); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_empty_slice_denies() { - // Strategies: [GraceHoldStream] - // Grace was at index 0, remaining slice is empty -> exhausted. - let strategies = vec![AdmissionStrategy::GraceHoldStream]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - - let addr: SocketAddr = "10.0.0.5:55901".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "no-remaining-strategies", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: true, - session_token: Some("tok-new"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), - }, - &grace_context, - None, - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::Exhausted, - "empty remaining slice should deny" - ); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_preserves_soft_kind_on_exhausted() { - // Strategies: [GraceHoldStream] - // Grace was at index 0, remaining slice is empty -> exhausted. - // grace_context.kind is Soft — must be preserved in the exhausted result. - let strategies = vec![AdmissionStrategy::GraceHoldStream]; - let grace_context = GraceResolutionContext { - strategy_index: 0, - strategies: strategies.into(), - kind: Some(crate::api::model::ConnectionKind::Soft), - }; - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - - let addr: SocketAddr = "10.0.0.6:55902".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "soft-kind-user", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: true, - session_token: Some("tok-soft"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-soft"), - }, - &grace_context, - Some(crate::api::model::ConnectionKind::Soft), - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::Exhausted, - "empty remaining slice should deny" - ); - assert_eq!( - result.admission.kind, - Some(crate::api::model::ConnectionKind::Soft), - "exhausted result must preserve the original Soft connection kind" - ); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_does_not_retry_used_prefix() { - // Strategies: [GraceHoldStream, GraceInstantStream, EvictUserOldest] - // Grace was at index 1 (GraceInstantStream). - // Remaining slice: [EvictUserOldest] (index 2). - // GraceHoldStream (index 0) must NOT be re-evaluated. - let strategies = vec![ - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::GraceInstantStream, - AdmissionStrategy::EvictUserOldest, - ]; - let strategies_for_config = strategies.clone(); - let grace_context = GraceResolutionContext { strategy_index: 1, strategies: strategies.into(), kind: None }; - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(strategies_for_config), - }); - - let addr1: SocketAddr = "127.0.0.1:56001".parse().unwrap_or_else(|_| unreachable!()); - let addr2: SocketAddr = "10.0.0.5:56002".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint1 = create_test_fingerprint(addr1); - let fingerprint2 = create_test_fingerprint(addr2); - - app_state.connection_manager.add_connection(&addr1).await; - app_state.connection_manager.add_connection(&addr2).await; - - let mut user = ProxyUserCredentials::default(); - user.username = "remaining-no-retry".to_string(); - user.max_connections = 1; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-counted", - virtual_id: 56001, - provider: "provider-no-retry", - stream_url: "http://provider.example/live/1.ts", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 56001, - meter_uid: 56001, - username: "remaining-no-retry", - max_connections: 1, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint1, - provider: "provider-no-retry".intern(), - stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-counted"), - }) - .await - .expect("stream should be created"); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "remaining-no-retry", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint2.client_ip, - request_addr: &fingerprint2.addr, - use_session_admission: true, - session_token: Some("tok-new"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), - }, - &grace_context, - Some(crate::api::model::ConnectionKind::Normal), - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::Allowed, - "only EvictUserOldest should be evaluated, not GraceHoldStream" - ); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_empty_slice_uses_original_kind_not_context_kind() { - // grace_context.kind = Normal, original_kind = Soft - // remaining slice is empty -> exhausted result must use original_kind. - // This proves the empty-slice branch uses original_kind, not grace_context.kind. - let strategies = vec![AdmissionStrategy::GraceHoldStream]; - let grace_context = GraceResolutionContext { - strategy_index: 0, - strategies: strategies.into(), - kind: Some(crate::api::model::ConnectionKind::Normal), - }; - let original_kind = Some(crate::api::model::ConnectionKind::Soft); - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), - }); - - let addr: SocketAddr = "10.0.0.7:55903".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "kind-mismatch-empty", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: true, - session_token: Some("tok-empty"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-empty"), - }, - &grace_context, - original_kind, - ) - .await; - - assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted); - assert_eq!( - result.admission.kind, original_kind, - "exhausted result must use original_kind (Soft), not grace_context.kind (Normal)" - ); - } - - #[tokio::test] - async fn evaluate_remaining_strategies_later_grace_uses_original_kind_not_context_kind() { - // grace_context.kind = Normal, original_kind = Soft - // Strategies: [GraceHoldStream, GraceInstantStream] - // Grace was used at index 0 (GraceHoldStream). - // Remaining slice contains GraceInstantStream (index 1). - // When the helper returns Grace for the remaining strategy, the new - // GraceResolutionContext.kind must be original_kind (Soft), not grace_context.kind (Normal). - // This proves build_grace_ctx uses original_kind as source of truth. - let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]; - let grace_context = GraceResolutionContext { - strategy_index: 0, - strategies: strategies.into(), - kind: Some(crate::api::model::ConnectionKind::Normal), - }; - let original_kind = Some(crate::api::model::ConnectionKind::Soft); - - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]), - }); - - let addr1: SocketAddr = "127.0.0.1:55710".parse().unwrap_or_else(|_| unreachable!()); - let addr2: SocketAddr = "10.0.0.8:55711".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint1 = create_test_fingerprint(addr1); - let fingerprint2 = create_test_fingerprint(addr2); - - app_state.connection_manager.add_connection(&addr1).await; - app_state.connection_manager.add_connection(&addr2).await; - - let mut user = ProxyUserCredentials::default(); - user.username = "kind-mismatch-grace".to_string(); - user.max_connections = 1; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-counted-grace", - virtual_id: 55710, - provider: "provider-grace-kind", - stream_url: "http://provider.example/live/1.ts", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 55710, - meter_uid: 55710, - username: "kind-mismatch-grace", - max_connections: 1, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint1, - provider: "provider-grace-kind".intern(), - stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-counted-grace"), - }) - .await - .expect("stream should be created"); - - let result = evaluate_remaining_strategies_after_grace( - &app_state.admission_ctx(), - AdmissionRequest { - username: "kind-mismatch-grace", - max_connections: 1, - soft_connections: 0, - client_ip: &fingerprint2.client_ip, - request_addr: &fingerprint2.addr, - use_session_admission: true, - session_token: Some("tok-new-grace"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-grace"), - }, - &grace_context, - original_kind, - ) - .await; - - assert_eq!( - result.admission.permission, - UserConnectionPermission::GracePeriod, - "remaining GraceInstantStream should grant GracePeriod" - ); - assert!(result.grace_context.is_some(), "grace_context must be present when grace is granted"); - assert_eq!( - result.grace_context.as_ref().unwrap().kind, - original_kind, - "GraceResolutionContext.kind in the result must be original_kind (Soft), not grace_context.kind (Normal)" - ); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn resolve_admission_with_strategies_falls_through_after_failed_grace_grant() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), - }); - - let first_addr: std::net::SocketAddr = "127.0.0.1:55151".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: std::net::SocketAddr = "127.0.0.1:55152".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let second_fingerprint = create_test_fingerprint(second_addr); - - app_state.connection_manager.add_connection(&first_addr).await; - app_state.connection_manager.add_connection(&second_addr).await; - - let mut session_user = ProxyUserCredentials::default(); - session_user.username = "fallthrough".to_string(); - session_user.max_connections = 1; - session_user.soft_connections = 1; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "tok-first", - virtual_id: 1, - provider: "provider-a", - stream_url: "http://provider-1.example/live/1.ts", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: "fallthrough", - max_connections: 1, - soft_connections: 1, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 10, - fingerprint: &first_fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/live/1.ts"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some("tok-first"), - }) - .await; - - assert!(app_state.active_users.grant_grace("fallthrough").await); - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "tok-second", - virtual_id: 2, - provider: "provider-a", - stream_url: "http://provider-1.example/live/2.ts", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Soft), - socket_bound: false, - }) - .await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: "fallthrough", - max_connections: 1, - soft_connections: 1, - connection_kind: crate::api::model::ConnectionKind::Soft, - priority: 0, - soft_priority: 10, - fingerprint: &second_fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/live/2.ts"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some("tok-second"), - }) - .await; - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: "fallthrough", - max_connections: 1, - soft_connections: 1, - client_ip: "127.0.0.1", - request_addr: &"127.0.0.1:55153".parse().unwrap_or_else(|_| unreachable!()), - use_session_admission: true, - session_token: Some("tok-third"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-third"), - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Normal)); - assert_eq!(grace_mode, None); - } - - #[tokio::test] - async fn resolve_admission_with_strategies_allows_existing_session_even_when_user_is_at_limit() { - let app_state = create_test_app_state(); - let addr = "127.0.0.1:55154".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let mut user = ProxyUserCredentials::default(); - user.username = "session-admission".to_string(); - user.max_connections = 1; - - app_state.connection_manager.add_connection(&addr).await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "vod-session", - virtual_id: 1, - provider: "provider-a", - stream_url: "http://provider-1.example/movie/1.mkv", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 10, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/movie/1.mkv"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some("vod-session"), - }) - .await; - - let session_based = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: true, - session_token: Some("vod-session"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), - }, - ) - .await; - assert_eq!(session_based.admission.permission, UserConnectionPermission::Allowed); - - let connection_based = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &fingerprint.client_ip, - request_addr: &fingerprint.addr, - use_session_admission: false, - session_token: Some("vod-session"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), - }, - ) - .await; - assert_eq!(connection_based.admission.permission, UserConnectionPermission::Exhausted); - } - - #[tokio::test] - async fn resolve_admission_with_strategies_prevents_recently_evicted_playback_ping_pong() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 0, - grace_period_timeout_secs: 8, - grace_period_hold_stream: false, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), - }); - - let victim_addr: std::net::SocketAddr = "127.0.0.1:55181".parse().unwrap_or_else(|_| unreachable!()); - let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55182".parse().unwrap_or_else(|_| unreachable!()); - let winner_addr: std::net::SocketAddr = "127.0.0.1:55183".parse().unwrap_or_else(|_| unreachable!()); - let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); - let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0"); - let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); - let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9001.ts"); - victim_channel.virtual_id = 9001; - let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9002.ts"); - winner_channel.virtual_id = 9002; - - app_state.connection_manager.add_connection(&victim_addr).await; - app_state.connection_manager.add_connection(&winner_addr).await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: "loop-user", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &victim_fingerprint, - provider: "provider-a".intern(), - stream_channel: &victim_channel, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("session-victim"), - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: "loop-user", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &winner_fingerprint, - provider: "provider-a".intern(), - stream_channel: &winner_channel, - user_agent: std::borrow::Cow::Borrowed("winner/1.0"), - session_token: Some("session-winner"), - }) - .await; - - app_state - .active_users - .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) - .await; - app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: "loop-user", - max_connections: 1, - soft_connections: 0, - client_ip: &reconnect_fingerprint.client_ip, - request_addr: &reconnect_fingerprint.addr, - use_session_admission: true, - session_token: Some("socket-reconnect"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9001) }, - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - - assert_eq!(admission.permission, UserConnectionPermission::Exhausted); - assert_eq!(grace_mode, None); - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1); - assert_eq!(active_streams[0].channel.virtual_id, 9002); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn resolve_admission_with_strategies_allows_other_channel_after_recent_eviction() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 0, - grace_period_timeout_secs: 8, - grace_period_hold_stream: false, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), - }); - - let victim_addr: std::net::SocketAddr = "127.0.0.1:55184".parse().unwrap_or_else(|_| unreachable!()); - let winner_addr: std::net::SocketAddr = "127.0.0.1:55185".parse().unwrap_or_else(|_| unreachable!()); - let new_addr: std::net::SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!()); - let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); - let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); - let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0"); - let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9101.ts"); - victim_channel.virtual_id = 9101; - let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9102.ts"); - winner_channel.virtual_id = 9102; - let mut session_user = ProxyUserCredentials::default(); - session_user.username = "loop-user-2".to_string(); - - app_state.connection_manager.add_connection(&victim_addr).await; - app_state.connection_manager.add_connection(&winner_addr).await; - - // Create sessions before update_connection so streams are linked to counted sessions - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "session-victim", - virtual_id: 9101, - provider: "provider-a", - stream_url: "http://provider-1.example/live/9101.ts", - addr: &victim_addr, - connection_permission: shared::model::UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "session-winner", - virtual_id: 9102, - provider: "provider-a", - stream_url: "http://provider-1.example/live/9102.ts", - addr: &winner_addr, - connection_permission: shared::model::UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: "loop-user-2", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &victim_fingerprint, - provider: "provider-a".intern(), - stream_channel: &victim_channel, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("session-victim"), - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: "loop-user-2", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &winner_fingerprint, - provider: "provider-a".intern(), - stream_channel: &winner_channel, - user_agent: std::borrow::Cow::Borrowed("winner/1.0"), - session_token: Some("session-winner"), - }) - .await; - - app_state - .active_users - .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) - .await; - app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: "loop-user-2", - max_connections: 1, - soft_connections: 0, - client_ip: &new_fingerprint.client_ip, - request_addr: &new_fingerprint.addr, - use_session_admission: true, - session_token: Some("session-new"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9103) }, - }, - ) - .await; - let admission = result.admission; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn resolve_admission_with_strategies_does_not_suppress_different_session_on_same_channel() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 0, - grace_period_timeout_secs: 8, - grace_period_hold_stream: false, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), - }); - - let victim_addr: std::net::SocketAddr = "127.0.0.1:55190".parse().unwrap_or_else(|_| unreachable!()); - let winner_addr: std::net::SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!()); - let new_addr: std::net::SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!()); - let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); - let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "player/1.0"); - let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0"); - let mut channel = create_test_live_channel("http://provider-1.example/live/9301.m3u8"); - channel.virtual_id = 9301; - channel.item_type = PlaylistItemType::LiveHls; - let mut session_user = ProxyUserCredentials::default(); - session_user.username = "loop-user-4".to_string(); - - app_state.connection_manager.add_connection(&victim_addr).await; - app_state.connection_manager.add_connection(&winner_addr).await; - - // Create sessions before update_connection so streams are linked to counted sessions - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "session-victim", - virtual_id: 9301, - provider: "provider-a", - stream_url: "http://provider-1.example/live/9301.m3u8", - addr: &victim_addr, - connection_permission: shared::model::UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &session_user, - session_token: "session-winner", - virtual_id: 9301, - provider: "provider-a", - stream_url: "http://provider-1.example/live/9301.m3u8", - addr: &winner_addr, - connection_permission: shared::model::UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: "loop-user-4", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &victim_fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("session-victim"), - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: "loop-user-4", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &winner_fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("session-winner"), - }) - .await; - - app_state - .active_users - .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) - .await; - app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: "loop-user-4", - max_connections: 1, - soft_connections: 0, - client_ip: &new_fingerprint.client_ip, - request_addr: &new_fingerprint.addr, - use_session_admission: true, - session_token: Some("session-other"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::Session("session-other"), - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(grace_mode, None); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn resolve_admission_with_strategies_allows_recently_evicted_playback_when_soft_slot_is_free() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 0, - grace_period_timeout_secs: 8, - grace_period_hold_stream: false, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), - }); - - let victim_addr: std::net::SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!()); - let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55188".parse().unwrap_or_else(|_| unreachable!()); - let winner_addr: std::net::SocketAddr = "127.0.0.1:55189".parse().unwrap_or_else(|_| unreachable!()); - let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); - let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0"); - let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); - let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9201.ts"); - victim_channel.virtual_id = 9201; - let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9202.ts"); - winner_channel.virtual_id = 9202; - - app_state.connection_manager.add_connection(&victim_addr).await; - app_state.connection_manager.add_connection(&winner_addr).await; - - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: "loop-user-3", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &victim_fingerprint, - provider: "provider-a".intern(), - stream_channel: &victim_channel, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("session-victim"), - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: "loop-user-3", - max_connections: 2, - soft_connections: 0, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &winner_fingerprint, - provider: "provider-a".intern(), - stream_channel: &winner_channel, - user_agent: std::borrow::Cow::Borrowed("winner/1.0"), - session_token: Some("session-winner"), - }) - .await; - - app_state - .active_users - .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) - .await; - app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; - - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: "loop-user-3", - max_connections: 1, - soft_connections: 1, - client_ip: &reconnect_fingerprint.client_ip, - request_addr: &reconnect_fingerprint.addr, - use_session_admission: true, - session_token: Some("socket-reconnect"), - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9201) }, - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft)); - assert_eq!(grace_mode, None); - - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1); - assert_eq!(active_streams[0].channel.virtual_id, 9202); - } - - #[tokio::test] - async fn local_stream_response_registers_active_local_stream() { - let app_state = create_test_app_state(); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let addr = "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let user = ProxyUserCredentials::default(); - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - - let _response = local_stream_response( - &fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - None, - None, - false, - ) - .await - .into_response(); - - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1, "local file streaming should register an active stream"); - assert_eq!(active_streams[0].channel.item_type, PlaylistItemType::LocalVideo); - } - - #[tokio::test] - async fn local_stream_response_rechecks_limits_before_registering_socket_bound_streams() { - let mut app_cfg = create_test_app_config(); - let config = Config { user_access_control: true, ..Config::default() }; - app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-race-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let first_addr = "127.0.0.1:55131".parse().unwrap_or_else(|_| unreachable!()); - let second_addr = "127.0.0.1:55132".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let second_fingerprint = create_test_fingerprint(second_addr); - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let mut user = ProxyUserCredentials::default(); - user.username = "local-limit-user".to_string(); - user.max_connections = 1; - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - let first_token = create_session_fingerprint(&first_fingerprint, &user.username, channel.virtual_id, true); - let second_token = create_session_fingerprint(&second_fingerprint, &user.username, channel.virtual_id, true); - - let _first_response = local_stream_response( - &first_fingerprint, - &app_state, - channel.clone(), - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - Some(&first_token), - None, - false, - ) - .await - .into_response(); - - let _second_response = local_stream_response( - &second_fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - Some(&second_token), - None, - false, - ) - .await - .into_response(); - - assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); - assert_eq!(app_state.active_users.active_streams().await.len(), 1); - assert_eq!( - app_state - .active_users - .connection_admission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - &second_token - ) - .await - .permission, - UserConnectionPermission::Exhausted, - "failed second open must not leave a placeholder session that bypasses admission" - ); - } - - #[tokio::test] - async fn stream_response_preserves_soft_kind_for_shared_reuse() { - let app_state = create_test_provider_app_state(); - let stream_url = "http://provider-1.example/live/shared.ts"; - let input_name = "provider_1".intern(); - let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist"); - let target = Arc::new(create_test_shared_target()); - - let owner_addr = "127.0.0.1:55140".parse().unwrap_or_else(|_| unreachable!()); - let owner_handle = app_state - .active_provider - .acquire_connection(&input.name, &owner_addr, 0, crate::api::model::ConnectionKind::Normal) - .await - .expect("owner allocation should exist"); - let shared_stream = stream::pending::>(); - let registered = SharedStreamManager::register_shared_stream( - SharedStreamCtx { - app_config: &app_state.app_config, - shared_stream_manager: &app_state.shared_stream_manager, - active_provider: &app_state.active_provider, - connection_manager: &app_state.connection_manager, - }, - stream_url, - shared_stream, - &owner_addr, - Vec::new(), - 1, - Some(owner_handle), - 0, - crate::api::model::ConnectionKind::Normal, - ) - .await; - assert!(registered.is_some(), "shared stream should register"); - - let mut user = ProxyUserCredentials::default(); - user.username = "soft-user".to_string(); - user.max_connections = 1; - user.soft_connections = 1; - user.priority = 0; - user.soft_priority = 9; - - let normal_addr = "127.0.0.1:55141".parse().unwrap_or_else(|_| unreachable!()); - let normal_fingerprint = create_test_fingerprint(normal_addr); - let normal_channel = create_test_live_channel("http://provider-1.example/live/normal.ts"); - app_state.active_users.add_connection(&normal_addr).await; - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 1001, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &normal_fingerprint, - provider: input.name.clone(), - stream_channel: &normal_channel, - user_agent: Cow::Borrowed("ua"), - session_token: Some("normal-session"), - }) - .await - .expect("normal stream should register"); - - let admission = app_state - .active_users - .connection_admission(&user.username, user.max_connections, user.soft_connections) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft)); - - let soft_addr = "127.0.0.1:55142".parse().unwrap_or_else(|_| unreachable!()); - let soft_fingerprint = create_test_fingerprint(soft_addr); - let response = stream_response( - &soft_fingerprint, - &app_state, - "soft-session", - None, - create_test_live_channel(stream_url), - stream_url, - None, - &HeaderMap::default(), - &input, - &target, - &user, - admission.permission, - admission.kind.unwrap_or(crate::api::model::ConnectionKind::Normal), - false, - None, - ) - .await - .into_response(); - assert_eq!(response.status(), StatusCode::OK); - - let session_admission = app_state - .active_users - .connection_admission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - "soft-session", - ) - .await; - assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); - } - - #[tokio::test] - async fn stream_response_rolls_back_provisional_user_activation_when_provider_open_fails() { - let mut app_cfg = create_test_provider_app_config(); - app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { - user_access_control: true, - custom_stream_response_enabled: true, - ..Config::default() - })); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); - let addr = "127.0.0.1:55143".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let input_name = "provider_1".intern(); - let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist"); - let target = Arc::new(ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }); - let mut user = ProxyUserCredentials::default(); - user.username = "rollback-user".to_string(); - user.max_connections = 1; - let stream_url = "provider://bad-url"; - let channel = create_test_live_channel(stream_url); - - let response = stream_response( - &fingerprint, - &app_state, - "rollback-session", - None, - channel, - stream_url, - None, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - false, - None, - ) - .await - .into_response(); - - // Custom-video stream is enabled (`custom_stream_response_enabled: true` - // in this fixture), so a missing resource must return 400 — the - // Nginx `proxy_intercept_errors on;` contract requires 4xx so the - // socket is severed instead of looping on a 200 OK fallback body. - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - app_state.active_users.user_connections(&user.username).await, - 0, - "failed provider open must rollback provisional user activation" - ); - assert!( - app_state.active_users.get_and_update_user_session(&user.username, "rollback-session").await.is_none(), - "failed provider open must remove the provisional placeholder session" - ); - } - - /// 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, - provider_session_headers: HashMap::new(), - grace_period: GracePeriodOptions::default(), - provider_grace_active: false, - disable_provider_grace: false, - reconnect_flag: None, - provider_handle: None, - content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, - 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, - provider_session_headers: HashMap::new(), - grace_period: GracePeriodOptions::default(), - provider_grace_active: false, - disable_provider_grace: false, - reconnect_flag: None, - provider_handle: None, - content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, - 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, - provider_session_headers: HashMap::new(), - grace_period: GracePeriodOptions::default(), - provider_grace_active: false, - disable_provider_grace: false, - reconnect_flag: None, - provider_handle: None, - content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, - 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(); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let addr = "127.0.0.1:55124".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = create_test_fingerprint(addr); - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let user = ProxyUserCredentials::default(); - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - - let response = local_stream_response( - &fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - None, - None, - false, - ) - .await - .into_response(); - - assert!(!should_compress_response(&response)); - } - - #[tokio::test] - async fn local_stream_response_reuses_stable_playback_session_token_across_reopens() { - let app_state = create_test_app_state(); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let user = ProxyUserCredentials::default(); - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - let playback_session_token = "local-playback-token"; - - let first_fingerprint = create_test_fingerprint("127.0.0.1:55125".parse().unwrap_or_else(|_| unreachable!())); - let second_fingerprint = create_test_fingerprint("127.0.0.1:55126".parse().unwrap_or_else(|_| unreachable!())); - - let _first_response = local_stream_response( - &first_fingerprint, - &app_state, - channel.clone(), - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - let _second_response = local_stream_response( - &second_fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1, "stable playback token should reuse the tracked local connection"); - assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); - assert_eq!(active_streams[0].addr, second_fingerprint.addr); - } - - #[tokio::test] - async fn local_stream_response_allows_exhausted_reopen_for_same_playback_session_token() { - let app_state = create_test_app_state(); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let mut user = ProxyUserCredentials::default(); - user.username = "user1".to_string(); - user.max_connections = 1; - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - let playback_session_token = "local-playback-token"; - - let first_fingerprint = create_test_fingerprint("127.0.0.1:55127".parse().unwrap_or_else(|_| unreachable!())); - let second_fingerprint = create_test_fingerprint("127.0.0.1:55128".parse().unwrap_or_else(|_| unreachable!())); - - let _first_response = local_stream_response( - &first_fingerprint, - &app_state, - channel.clone(), - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Normal, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - let second_response = local_stream_response( - &second_fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Exhausted, - crate::api::model::ConnectionKind::Normal, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - assert_eq!(second_response.status(), StatusCode::OK); - - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1); - assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); - assert_eq!(active_streams[0].addr, second_fingerprint.addr); - } - - #[tokio::test] - async fn local_stream_response_preserves_soft_kind_across_reopens() { - let app_state = create_test_app_state(); - let temp_dir = tempfile::tempdir().expect("tempdir"); - let file_path = temp_dir.path().join("local-soft-test.mkv"); - tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); - - let channel = create_test_local_channel(&format!("file://{}", file_path.display())); - let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; - let mut user = ProxyUserCredentials::default(); - user.username = "soft-local-user".to_string(); - user.max_connections = 1; - user.soft_connections = 1; - let target = ConfigTarget { - id: 1, - enabled: true, - name: "test".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - output: Vec::new(), - rename: None, - mapping_ids: None, - mapping: Arc::new(ArcSwapOption::default()), - favourites: None, - processing_order: ProcessingOrder::default(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: false, - }; - let playback_session_token = "local-soft-playback-token"; - - let first_fingerprint = create_test_fingerprint("127.0.0.1:55129".parse().unwrap_or_else(|_| unreachable!())); - let second_fingerprint = create_test_fingerprint("127.0.0.1:55130".parse().unwrap_or_else(|_| unreachable!())); - - let _first_response = local_stream_response( - &first_fingerprint, - &app_state, - channel.clone(), - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Allowed, - crate::api::model::ConnectionKind::Soft, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - let second_response = local_stream_response( - &second_fingerprint, - &app_state, - channel, - &HeaderMap::default(), - &input, - &target, - &user, - UserConnectionPermission::Exhausted, - crate::api::model::ConnectionKind::Normal, - Some(playback_session_token), - None, - false, - ) - .await - .into_response(); - - assert_eq!(second_response.status(), StatusCode::OK); - - let session_admission = app_state - .active_users - .connection_admission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - playback_session_token, - ) - .await; - assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); - } - - #[tokio::test] - async fn activated_session_admission_keeps_hls_placeholders_uncounted_via_api_utils() { - let app_state = create_test_app_state(); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.max_connections = 1; - - let first_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: std::net::SocketAddr = "127.0.0.1:55178".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let second_fingerprint = create_test_fingerprint(second_addr); - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-hls-first", - virtual_id: 7101, - provider: "provider-a", - stream_url: "http://provider-1.example/live/7101.m3u8", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-hls-second", - virtual_id: 7102, - provider: "provider-a", - stream_url: "http://provider-1.example/live/7102.m3u8", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let first_admission = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &first_fingerprint.client_ip, - request_addr: &first_fingerprint.addr, - use_session_admission: true, - session_token: Some("tok-hls-first"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-first"), - }, - ) - .await; - let second_admission = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &second_fingerprint.client_ip, - request_addr: &second_fingerprint.addr, - use_session_admission: true, - session_token: Some("tok-hls-second"), - activate_unbound_session: true, - eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-second"), - }, - ) - .await; - - assert_eq!(first_admission.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(second_admission.admission.permission, UserConnectionPermission::Allowed); - assert_eq!(app_state.active_users.user_connections(&user.username).await, 0); - } - - #[tokio::test] - async fn socket_bound_playback_sessions_enforce_hard_limits_per_socket() { - let app_state = create_test_app_state(); - let mut user = ProxyUserCredentials::default(); - user.username = "user1".to_string(); - user.max_connections = 1; - - let first_addr: std::net::SocketAddr = "127.0.0.1:55171".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: std::net::SocketAddr = "127.0.0.1:55172".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 5001, true); - let second_fingerprint = create_test_fingerprint(second_addr); - let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 5001, true); - - app_state.connection_manager.add_connection(&first_addr).await; - app_state.connection_manager.add_connection(&second_addr).await; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: &first_token, - virtual_id: 5001, - provider: "provider-a", - stream_url: "http://provider-1.example/vod/5001.ts", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 5001, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &first_fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/vod/5001.ts"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some(&first_token), - }) - .await; - - let admission = app_state - .active_users - .connection_admission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - &second_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Exhausted); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn resolve_admission_with_strategies_evicts_preserved_hls_session_for_same_user_ts_request() { - let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![ - AdmissionStrategy::EvictUserSameIpOldest, - AdmissionStrategy::EvictUserSameIpLatest, - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::EvictUserOldest, - AdmissionStrategy::EvictUserLatest, - ]), - }); - - let hls_addr: std::net::SocketAddr = "127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!()); - let ts_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!()); - let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "player/1.0"); - let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "player/1.0"); - let mut user = ProxyUserCredentials::default(); - user.username = "same-user".to_string(); - user.max_connections = 1; - - app_state.connection_manager.add_connection(&hls_addr).await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: "tok-hls-preserved", - virtual_id: 5001, - provider: "provider-a", - stream_url: "http://provider-1.example/live/5001.m3u8", - addr: &hls_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &hls_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { - item_type: PlaylistItemType::LiveHls, - virtual_id: 5001, - ..create_test_live_channel("http://provider-1.example/live/5001.m3u8") - }, - user_agent: std::borrow::Cow::Borrowed("player/1.0"), - session_token: Some("tok-hls-preserved"), - }) - .await; - - app_state.connection_manager.release_connection(&hls_addr).await; - assert_eq!(app_state.active_users.user_connections(&user.username).await, 0); - assert!(app_state.active_users.active_streams().await.is_empty()); - - let mut close_rx = app_state.connection_manager.get_close_connection_channel(); - let result = resolve_admission_with_strategies( - &app_state.admission_ctx(), - AdmissionRequest { - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - client_ip: &ts_fingerprint.client_ip, - request_addr: &ts_fingerprint.addr, - use_session_admission: false, - session_token: None, - activate_unbound_session: false, - eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(5001) }, - }, - ) - .await; - let admission = result.admission; - let grace_mode = result.grace_mode; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(grace_mode, None); - assert!(app_state.active_users.active_streams().await.is_empty()); - assert_eq!( - tokio::time::timeout(std::time::Duration::from_millis(100), close_rx.recv()) - .await - .ok() - .and_then(Result::ok), - Some(crate::api::model::CloseConnectionSignal::WithReason( - hls_addr, - shared::model::DisconnectReason::ClientKicked, - )) - ); - assert!( - app_state.active_users.get_and_update_user_session(&user.username, "tok-hls-preserved").await.is_none(), - "preserved session should be removed once the TS request evicts it" - ); - } - - #[tokio::test] - async fn socket_bound_playback_sessions_still_allow_soft_slots() { - let app_state = create_test_app_state(); - let mut user = ProxyUserCredentials::default(); - user.username = "soft-user".to_string(); - user.max_connections = 1; - user.soft_connections = 1; - user.priority = 0; - user.soft_priority = 9; - - let first_addr: std::net::SocketAddr = "127.0.0.1:55173".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: std::net::SocketAddr = "127.0.0.1:55174".parse().unwrap_or_else(|_| unreachable!()); - let third_addr: std::net::SocketAddr = "127.0.0.1:55175".parse().unwrap_or_else(|_| unreachable!()); - let first_fingerprint = create_test_fingerprint(first_addr); - let second_fingerprint = create_test_fingerprint(second_addr); - let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 6001, true); - let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 6001, true); - let third_fingerprint = create_test_fingerprint(third_addr); - let third_token = create_session_fingerprint(&third_fingerprint, &user.username, 6001, true); - - app_state.connection_manager.add_connection(&first_addr).await; - app_state.connection_manager.add_connection(&second_addr).await; - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: &first_token, - virtual_id: 6001, - provider: "provider-a", - stream_url: "http://provider-1.example/vod/6001.ts", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: true, - }) - .await; - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 6001, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &first_fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/vod/6001.ts"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some(&first_token), - }) - .await; - - let second_admission = app_state - .active_users - .connection_admission_for_session( - &user.username, - user.max_connections, - user.soft_connections, - &second_token, - ) - .await; - assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(second_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); - - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: &second_token, - virtual_id: 6001, - provider: "provider-a", - stream_url: "http://provider-1.example/vod/6001.ts", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Soft), - socket_bound: true, - }) - .await; - app_state - .active_users - .update_connection(crate::api::model::ActiveUserConnectionParams { - uid: 6002, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Soft, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &second_fingerprint, - provider: "provider-a".intern(), - stream_channel: &create_test_live_channel("http://provider-1.example/vod/6002.ts"), - user_agent: std::borrow::Cow::Borrowed("ua"), - session_token: Some(&second_token), - }) - .await; - - let third_admission = app_state - .active_users - .connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &third_token) - .await; - assert_eq!(third_admission.permission, UserConnectionPermission::Exhausted); - } - - #[test] - fn session_based_playback_matches_adaptive_types_and_extensions() { - assert!(is_session_based_playback(PlaylistItemType::LiveHls, None)); - assert!(is_session_based_playback(PlaylistItemType::LiveDash, None)); - assert!(is_session_based_playback(PlaylistItemType::Live, Some(HLS_EXT))); - assert!(is_session_based_playback(PlaylistItemType::Live, Some(DASH_EXT))); - assert!(!is_session_based_playback(PlaylistItemType::Video, None)); - } - - #[test] - fn create_session_fingerprint_switches_between_logical_and_socket_bound_keys() { - let fingerprint = create_test_fingerprint("127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!())); - let logical = create_session_fingerprint(&fingerprint, "user1", 7001, false); - let socket_bound = create_session_fingerprint(&fingerprint, "user1", 7001, true); - - assert_ne!(logical, socket_bound); - assert!(logical.contains(&fingerprint.key)); - assert!(socket_bound.contains(&fingerprint.addr.to_string())); - } - - #[test] - fn adaptive_playback_session_fingerprint_is_logical_across_initial_sockets() { - let Some(first_addr) = "127.0.0.1:55177".parse().ok() else { - return; - }; - let Some(second_addr) = "127.0.0.1:55178".parse().ok() else { - return; - }; - let first = Fingerprint::new("10.0.0.6|player".to_string(), "10.0.0.6".to_string(), first_addr); - let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr); - - 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_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] - fn playback_session_fingerprint_keeps_ts_socket_bound_but_vod_logical() { - let first_addr: SocketAddr = "127.0.0.1:55179".parse().unwrap_or_else(|_| unreachable!()); - let second_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap_or_else(|_| unreachable!()); - let first = Fingerprint::new("10.0.0.7|player".to_string(), "10.0.0.7".to_string(), first_addr); - let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr); - - let first_ts = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Live, None); - let second_ts = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Live, None); - let first_vod = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Video, None); - let second_vod = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Video, None); - - assert_ne!(first_ts, second_ts, "plain TS live remains socket-bound"); - assert_eq!(first_vod, second_vod, "VOD remains logical across reopen/seek sockets"); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn xtream_hls_then_ts_uses_distinct_tokens_and_evicts_old_hls_session() { - let mut app_cfg = create_test_app_config(); - let config = Config { - user_access_control: true, - reverse_proxy: Some(crate::model::ReverseProxyConfig { - resource_rewrite_disabled: false, - rewrite_secret: [0; 16], - resource_retry: crate::model::ResourceRetryConfig::default(), - disabled_header: None, - stream: Some(crate::model::StreamConfig { - retry: true, - metrics_enabled: true, - buffer: None, - grace_period_millis: 2_000, - grace_period_timeout_secs: 8, - grace_period_hold_stream: true, - hls_session_ttl_secs: 10, - catchup_session_ttl_secs: 10, - throttle_str: None, - throttle_kbps: 0, - shared_burst_buffer_mb: 1, - shared_subscriber_idle_timeout_secs: 300, - admission_strategies: Some(vec![ - AdmissionStrategy::EvictUserSameIpOldest, - AdmissionStrategy::EvictUserSameIpLatest, - AdmissionStrategy::GraceHoldStream, - AdmissionStrategy::EvictUserOldest, - AdmissionStrategy::EvictUserLatest, - ]), - }), - cache: None, - rate_limit: None, - geoip: None, - stream_history: None, - qos_aggregation: None, - hls_cache: None, - }), - ..Config::default() - }; - app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); - let hls_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!()); - let ts_addr: SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!()); - let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "libmpv"); - let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "libmpv"); - let mut user = ProxyUserCredentials::default(); - user.username = "xtream-hls-ts".to_string(); - user.max_connections = 1; - - let virtual_id = 7811; - let hls_token = create_session_fingerprint(&hls_fingerprint, &user.username, virtual_id, false); - let ts_token = create_session_fingerprint(&ts_fingerprint, &user.username, virtual_id, true); - assert_ne!(hls_token, ts_token, "Xtream .m3u8 and .ts must not share the same playback token"); - - let mut hls_channel = create_test_live_channel("http://provider-1.example/live/7811.m3u8"); - hls_channel.virtual_id = virtual_id; - hls_channel.item_type = PlaylistItemType::LiveHls; - let mut ts_channel = create_test_live_channel("http://provider-1.example/live/7811.ts"); - ts_channel.virtual_id = virtual_id; - - app_state.connection_manager.add_connection(&hls_addr).await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: &hls_token, - virtual_id, - provider: "provider_1", - stream_url: hls_channel.url.as_ref(), - addr: &hls_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - socket_bound: false, - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 1, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &hls_fingerprint, - provider: "provider_1".intern(), - stream_channel: &hls_channel, - user_agent: Cow::Borrowed("libmpv"), - session_token: Some(&hls_token), - }) - .await; - - app_state.connection_manager.release_connection(&hls_addr).await; - assert_eq!( - app_state.active_users.user_connections(&user.username).await, - 0, - "the preserved HLS playback must reserve capacity only virtually" - ); - assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0)); - assert!( - app_state.active_users.get_and_update_user_session(&user.username, &hls_token).await.is_some(), - "preserved HLS session should still exist before the competing TS request" - ); - assert_eq!( - app_state - .active_users - .connection_admission(&user.username, user.max_connections, user.soft_connections) - .await - .permission, - UserConnectionPermission::Exhausted, - "the preserved HLS playback must still reserve the user's only slot before the TS request is evaluated" - ); - assert_eq!( - app_state.active_users.get_eviction_candidates(&user.username, &ts_fingerprint.client_ip).await.len(), - 1, - "the preserved HLS playback should be the single eviction candidate for the competing TS request" - ); - - let (ts_admission, ts_grace_mode, request_class) = resolve_playback_request_admission( - &app_state.admission_ctx(), - &user, - &ts_fingerprint, - None, - &ts_token, - false, - EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, - false, - false, - ) - .await; - assert_eq!(request_class, PlaybackRequestClass::Activate); - assert_eq!(ts_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(ts_grace_mode, None); - assert!( - app_state - .active_users - .get_and_update_user_session(&user.username, &hls_token) - .await - .is_none(), - "the competing TS activation must remove the old preserved HLS session even though there is no live socket left to kick" - ); - assert_eq!( - app_state.active_users.user_connections(&user.username).await, - 0, - "eviction must not leave a real slot before the TS stream commits" - ); - assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0)); - - app_state.connection_manager.add_connection(&ts_addr).await; - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user: &user, - session_token: &ts_token, - virtual_id, - provider: "provider_1", - stream_url: ts_channel.url.as_ref(), - addr: &ts_addr, - connection_permission: ts_admission.permission, - connection_kind: ts_admission.kind, - socket_bound: true, - }) - .await; - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 2, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: crate::api::model::ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &ts_fingerprint, - provider: "provider_1".intern(), - stream_channel: &ts_channel, - user_agent: Cow::Borrowed("libmpv"), - session_token: Some(&ts_token), - }) - .await; - - assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); - assert_eq!(app_state.active_users.active_users_and_connections().await, (1, 1)); - let active_streams = app_state.active_users.active_streams().await; - assert_eq!(active_streams.len(), 1); - assert_eq!(active_streams.first().and_then(|stream| stream.session_token.as_deref()), Some(ts_token.as_str())); - assert!( - app_state - .active_users - .get_and_update_user_session(&user.username, &hls_token) - .await - .is_none(), - "after the competing TS request, the old Xtream HLS session must be gone so later /hls segment fetches cannot revive it" - ); - assert!( - app_state.active_users.get_and_update_user_session(&user.username, &ts_token).await.is_some(), - "the winning TS playback should remain tracked under its socket-bound Xtream token" - ); - } - - #[test] - fn socket_bound_playback_session_matches_only_plain_live_playback() { - assert!(is_socket_bound_playback_session(PlaylistItemType::Live, None)); - assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(HLS_EXT))); - assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(DASH_EXT))); - assert!(!is_socket_bound_playback_session(PlaylistItemType::LiveHls, None)); - assert!(!is_socket_bound_playback_session(PlaylistItemType::Video, None)); - assert!(!is_socket_bound_playback_session(PlaylistItemType::Series, None)); - assert!(!is_socket_bound_playback_session(PlaylistItemType::Catchup, None)); - } - - #[test] - fn session_reacquire_cleanup_addrs_excludes_current_and_deduplicates() { - let primary: SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!()); - let overlap: SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!()); - let seek: SocketAddr = "127.0.0.1:55193".parse().unwrap_or_else(|_| unreachable!()); - let session = UserSession { - token: "tok-vod".to_string(), - transition_version: 1, - 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], - ts: 1, - started_at: 1, - permission: UserConnectionPermission::Allowed, - connection_kind: Some(crate::api::model::ConnectionKind::Normal), - lifecycle: crate::api::model::PlaybackLifecycle::Active, - }; - - assert_eq!(session_reacquire_cleanup_addrs(&session, &seek), vec![primary, overlap]); - } - - #[tokio::test] - async fn intentional_deferred_open_retains_provider_grace_handle() { - let app_state = create_test_provider_app_state(); - let provider_name = "provider_1".intern(); - let holder_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); - let deferred_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); - let holder_handle = app_state - .active_provider - .acquire_exact_connection_with_grace( - &provider_name, - &holder_addr, - false, - 0, - crate::api::model::ConnectionKind::Normal, - ) - .await - .expect("holder occupies the provider slot"); - let input = app_state.app_config.get_input_by_name(&provider_name).expect("provider input"); - let stream_url = "http://provider-1.example/live/user1/pass1/100.m3u8"; - let mut channel = create_test_live_channel(stream_url); - channel.item_type = PlaylistItemType::LiveHls; - let fingerprint = create_test_fingerprint(deferred_addr); - - let mut details = create_stream_response_details( - &app_state, - &get_stream_options(&app_state.app_config), - stream_url, - "deferred-user", - &fingerprint, - &HeaderMap::new(), - &input, - &channel, - PlaylistItemType::LiveHls, - crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, - false, - UserConnectionPermission::Allowed, - None, - true, - true, - VirtualId::new(channel.virtual_id), - 0, - crate::api::model::ConnectionKind::Normal, - false, - Some("deferred-session"), - None, - false, - Some(true), - None, - ) - .await - .expect("provider grace creates deferred stream details"); - - assert!(details.stream.is_none()); - assert!(details.has_deferred_provider_open()); - assert!(details.provider_handle.is_some(), "deferred open must retain its provider allocation"); - - app_state.connection_manager.release_provider_handle(details.provider_handle.take()).await; - app_state.connection_manager.release_provider_handle(Some(holder_handle)).await; - } - - #[test] - fn grace_hold_defers_live_and_fresh_video_but_not_catchup_or_affine_reopens() { - assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::LiveHls, false)); - assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, false)); - assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, false)); - assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, true)); - assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, true)); - assert!(!should_defer_provider_open_for_grace_hold(true, false, PlaylistItemType::Video, true)); - } - - #[tokio::test] - async fn forced_reopen_cleanup_for_adaptive_streams_does_not_close_client_socket() { - let app_state = create_test_app_state(); - let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!()); - let mut close_rx = app_state.connection_manager.get_close_connection_channel(); - - cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::LiveHls, &[addr]).await; - - let signal = - tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok); - assert!(signal.is_none(), "adaptive cleanup should not hard-close the previous client socket"); - } - - #[tokio::test] - async fn forced_reopen_cleanup_for_non_adaptive_streams_closes_client_socket() { - let app_state = create_test_app_state(); - let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!()); - let mut close_rx = app_state.connection_manager.get_close_connection_channel(); - - cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::Live, &[addr]).await; - - let signal = - tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok); - assert!(matches!( - signal, - Some(crate::api::model::CloseConnectionSignal::WithReason(signal_addr, _)) if signal_addr == addr - )); - } - - #[tokio::test] - async fn get_query_path_strips_extension_for_live_with_flag() { - use crate::model::ConfigInputFlags; - use shared::model::{InputType, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; - - let mut input = ConfigInput { - id: 1, - name: "provider_with_flag".intern(), - input_type: InputType::Xtream, - ..ConfigInput::default() - }; - let mut options = crate::model::ConfigInputOptions::defaults().clone(); - options.flags.set(ConfigInputFlags::XtreamLiveStreamWithoutExtension); - input.options = Some(options); - - let sources = SourcesConfig { inputs: vec![Arc::new(input)], ..SourcesConfig::default() }; - let mut app_cfg_raw = create_test_app_config(); - app_cfg_raw.sources = Arc::new(ArcSwap::from_pointee(sources)); - let app_state = create_test_app_state_for_config(Arc::new(app_cfg_raw)); - - let pli = XtreamPlaylistItem { - virtual_id: VirtualId::new(100), - provider_id: 1, - name: "test".intern(), - logo: "".intern(), - logo_small: "".intern(), - group: "".intern(), - title: "".intern(), - parent_code: "".intern(), - rec: "".intern(), - url: "http://example.com/123".intern(), - epg_channel_id: None, - xtream_cluster: XtreamCluster::Live, - additional_properties: None, - item_type: PlaylistItemType::Live, - category_id: 0, - input_name: "provider_with_flag".intern(), - channel_no: 0, - source_ordinal: 0, - input_stream_id: "1".intern(), - upstream_user_agent: None, - }; - - let hls_ext = shared::defaults::HLS_EXT.to_string(); - let (query_path, extension) = - crate::api::endpoints::xtream_api::get_query_path("", Some(&hls_ext), &pli, &app_state); - - assert_eq!(extension, ""); - assert_eq!(query_path, "1"); - - let dash_ext = shared::defaults::DASH_EXT.to_string(); - let (query_path, extension) = - crate::api::endpoints::xtream_api::get_query_path("", Some(&dash_ext), &pli, &app_state); - - assert_eq!(extension, ""); - assert_eq!(query_path, "1"); - } - - // ========================================================================================= - // evaluate_network_access tests - // ========================================================================================= - - /// Run `evaluate_network_access` for a synthetic user built from - /// `network_access` and assert the decision matches `expected`. Centralizes - /// the boilerplate (`user_with_network_access` + geoip setup + call + - /// assert) shared by every `evaluate_network_access` test below. - fn assert_network_decision( - network_access: Option, - geoip: &Arc>, - ip: &str, - expected: NetworkAccessDecision, - ) { - let user = user_with_network_access(network_access); - assert_eq!(evaluate_network_access(&user, ip, geoip, GeoIpUnavailablePolicy::Deny), expected); - } - - /// `Arc>` with no `GeoIP` database loaded. - fn empty_geoip() -> Arc> { Arc::new(ArcSwapOption::::default()) } - - /// `Arc>` with a mock `GeoIP` that always reports the - /// given country for any lookup. - fn mock_geoip(country: &str) -> Arc> { - Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new(country))))) - } - - /// Helper to create a test user with specific network access - fn user_with_network_access(network_access: Option) -> ProxyUserCredentials { - ProxyUserCredentials { - username: "test".to_string(), - password: "test".to_string(), - token: None, - proxy: ProxyType::default(), - server: None, - epg_timeshift: None, - epg_request_timeshift: None, - created_at: None, - exp_date: None, - max_connections: 0, - status: None, - output_clusters: ClusterFlags::all(), - ui_enabled: true, - comment: None, - priority: 0, - soft_connections: 0, - soft_priority: 0, - t_is_api_user: false, - network_access, - plan: None, - filter: None, - raw_output_clusters: None, - raw_max_connections: 0, - raw_soft_connections: 0, - raw_proxy: Some(ProxyType::default()), - t_filter: None, - t_has_unresolved_plan: false, - t_has_invalid_filter: false, - } - } - - #[test] - fn no_config_allows_all() { - assert_network_decision(None, &empty_geoip(), "192.168.1.1", NetworkAccessDecision::Allowed); - } - - #[test] - fn empty_config_allows_all() { - assert_network_decision( - Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec![] }), - &empty_geoip(), - "192.168.1.1", - NetworkAccessDecision::Allowed, - ); - } - - #[test] - fn cidr_match_allows() { - assert_network_decision( - Some(NetworkAccess { - allowed_countries: vec![], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - }), - &empty_geoip(), - "192.168.1.42", - NetworkAccessDecision::Allowed, - ); - } - - #[test] - fn cidr_miss_denies() { - assert_network_decision( - Some(NetworkAccess { - allowed_countries: vec![], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - }), - &empty_geoip(), - "10.0.0.1", - NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), - ); - } - - #[test] - fn country_match_allows() { - assert_network_decision( - Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), - &mock_geoip("DE"), - "8.8.8.8", - NetworkAccessDecision::Allowed, - ); - } - - #[test] - fn country_miss_denies() { - assert_network_decision( - Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), - &mock_geoip("US"), - "8.8.8.8", - NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch), - ); - } - - #[test] - fn no_geoip_denies_on_country_restriction() { - assert_network_decision( - Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), - &empty_geoip(), - "8.8.8.8", - NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable), - ); - } - - #[test] - fn ipv4_vs_ipv6_denies_gracefully() { - assert_network_decision( - Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["2001:db8::/32".parse().unwrap()] }), - &empty_geoip(), - "192.168.1.1", - NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), - ); - } - - #[test] - fn ipv6_vs_ipv4_denies_gracefully() { - assert_network_decision( - Some(NetworkAccess { - allowed_countries: vec![], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - }), - &empty_geoip(), - "2001:db8::1", - NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), - ); - } - - #[test] - fn either_cidr_or_country_match_allows() { - assert_network_decision( - Some(NetworkAccess { - allowed_countries: vec!["US".to_string()], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - }), - &mock_geoip("DE"), - "192.168.1.42", - NetworkAccessDecision::Allowed, - ); - } - - #[test] - fn single_ip_cidr() { - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec![], - allowed_networks: vec!["192.168.1.1/32".parse().unwrap()], - })); - let geoip = empty_geoip(); - assert_eq!( - evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Allowed - ); - assert_eq!( - evaluate_network_access(&user, "192.168.1.2", &geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch) - ); - } - - // ========================================================================================= - // network denied reason tests - // ========================================================================================= - - // The three `network_denied_reason_*` cases remain because they cover the - // `NetworkAccessDenyReason`-focused API surface directly, while - // `cidr_miss_denies`, `country_miss_denies`, and - // `no_geoip_denies_on_country_restriction` above assert the same deny - // reasons through broader `evaluate_network_access(...)` behavior. The - // overlap is intentional so both the general decision path and the - // reason-reporting-focused path stay pinned by tests. - - #[test] - fn network_denied_reason_country_unknown_when_geoip_loaded_but_unknown_ip() { - // GeoIP is loaded (not None), but lookup returns None for this IP (private/unknown). - // We need a GeoIP that only covers a private range, so public IPs get None. - // Use a CIDR-only restriction (no country rules) so we can verify - // that when countries ARE checked, lookup None gives "country_unknown". - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], // miss CIDR first - })); - // Use the real GeoIp::new() which only seeds private ranges. - // For 8.8.8.8 (public), lookup returns None. - let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new())))); - // CIDR miss -> country check -> geoip loaded but lookup returns None - assert_eq!( - evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown) - ); - } - - #[test] - fn network_denied_reason_none_when_allowed() { - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec![], - })); - let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE"))))); - assert_eq!( - evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Allowed - ); - } - - #[test] - fn network_denied_reason_none_when_no_config() { - let user = user_with_network_access(None); - let geoip = Arc::new(ArcSwapOption::::default()); - assert_eq!( - evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Allowed - ); - } - - // ========================================================================================= - // GeoIP unavailable policy tests - // ========================================================================================= - - #[test] - fn geoip_unavailable_default_deny_denies() { - // Country rule exists but GeoIP is unavailable — default policy is Deny - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec![], - })); - let geoip = Arc::new(ArcSwapOption::::default()); - let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny); - assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)); - } - - #[test] - fn geoip_unavailable_explicit_allow_allows() { - // Country rule exists, GeoIP unavailable, but policy is Allow — allows - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec![], - })); - let geoip = Arc::new(ArcSwapOption::::default()); - let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow); - assert_eq!(decision, NetworkAccessDecision::AllowedGeoIpUnavailable); - } - - #[test] - fn geoip_unavailable_cidr_only_still_denies() { - // CIDR only rules, no match — should deny even with Allow policy - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec![], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - })); - let geoip = Arc::new(ArcSwapOption::::default()); - let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Allow); - assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)); - } - - #[test] - fn geoip_unavailable_cidr_match_allows() { - // CIDR match always allows, regardless of policy - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec!["10.0.0.0/8".parse().unwrap()], - })); - let geoip = Arc::new(ArcSwapOption::::default()); - let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny); - assert_eq!(decision, NetworkAccessDecision::Allowed); - } - - #[test] - fn geoip_loaded_country_mismatch_still_denies() { - // Loaded GeoIP but country doesn't match — should deny under Allow policy - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec![], - })); - let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US"))))); - let decision = evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Allow); - assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch)); - } - - #[test] - fn geoip_loaded_unknown_country_still_denies() { - // Loaded GeoIP but lookup returns None — should deny under Allow policy - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], - })); - let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new())))); - let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow); - assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown)); - } - - #[test] - fn malformed_ip_denies_even_when_geoip_unavailable_policy_is_allow() { - let user = user_with_network_access(Some(NetworkAccess::from(&shared::model::NetworkAccessDto { - allowed_countries: Some(vec!["DE".to_string()]), - allowed_networks: None, - }))); - let geoip = Arc::new(ArcSwapOption::::default()); - - let decision = evaluate_network_access(&user, "not-an-ip", &geoip, GeoIpUnavailablePolicy::Allow); - - assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::MalformedClientIp)); - } - - #[test] - fn evaluate_network_access_respects_allow_policy() { - // verify evaluate_network_access returns AllowedGeoIpUnavailable with Allow policy - let user = user_with_network_access(Some(NetworkAccess { - allowed_countries: vec!["DE".to_string()], - allowed_networks: vec![], - })); - let geoip = Arc::new(ArcSwapOption::::default()); - // Default deny policy should return Denied - assert_eq!( - evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny), - NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable) - ); - // Allow policy should return AllowedGeoIpUnavailable - assert_eq!( - evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow), - NetworkAccessDecision::AllowedGeoIpUnavailable - ); - } -} diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs new file mode 100644 index 000000000..7192236c4 --- /dev/null +++ b/backend/app/src/api/api_utils/mod.rs @@ -0,0 +1,3925 @@ +pub use crate::repository::{ + evaluate_network_access, log_network_access_allowed_geoip_unavailable, log_network_access_denied, + NetworkAccessDecision, NetworkAccessDenyReason, +}; +use crate::{ + api::{ + endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}, + model::{ + create_active_client_stream, create_channel_unavailable_stream, create_custom_video_stream_response, + create_provider_connections_exhausted_stream, create_provider_stream, + get_custom_stream_response_error_status, get_stream_response_with_headers, is_custom_video_stream_enabled, + tee_stream, AppState, BoxedProviderStream, CustomVideoStreamType, PendingProviderReason, + ProviderAllocation, ProviderConfig, ProviderHandle, ProviderStreamCustomReason, + ProviderStreamFactoryOptions, ProviderStreamInfo, ProviderStreamState, SharedStreamCtx, + SharedStreamManager, StreamDetails, StreamError, StreamingStrategy, ThrottledStream, UserApiRequest, + UserSession, + }, + }, + auth::Fingerprint, + media_server::{ + playback::{ + media_server_image_response as open_media_server_proxy_image_response, + media_server_stream_response as open_media_server_proxy_stream_response, parse_media_server_image_ref, + parse_media_server_stream_ref, + }, + MediaServerError, MediaServerErrorKind, MediaServerHttpClient, MediaServerImageRef, + }, + model::{AppConfig, ConfigInput, ConfigTarget, InputUserInfo, ProxyUserCredentials}, + processing::{ + parser::hls::{rewrite_hls, RewriteHlsProps}, + processor::re_resolve_stalker_url, + }, + utils::{ + async_file_reader, async_file_writer, create_new_file_for_write, debug_if_enabled, get_file_extension, request, + request::{content_type_from_ext, parse_range, send_with_retry_and_provider}, + trace_if_enabled, + }, + BUILD_TIMESTAMP, +}; +use arc_swap::ArcSwapOption; +use axum::{ + body::Body, + http::{header, HeaderMap, HeaderName, HeaderValue, Response, StatusCode}, + response::IntoResponse, +}; +use bytes::{Bytes, BytesMut}; +use chrono::{DateTime, Utc}; +use futures::{stream, Stream, StreamExt, TryStreamExt}; +use log::{debug, error, info, log_enabled, trace, warn}; +use serde::Serialize; +use shared::{ + concat_string, + defaults::{DASH_EXT, HLS_EXT}, + model::{ + ConfigTargetOptions, InputFetchMethod, InputType, PlaylistEntry, PlaylistItemType, ProxyType, + StalkerStreamKind, StreamChannel, StreamInfo, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, + }, + utils::{ + bin_serialize, current_time_secs, extract_extension_from_url, get_credentials_from_url, human_readable_kbps, + is_sanitize_sensitive_info_enabled, replace_url_extension, sanitize_sensitive_info, trim_slash, Internable, + CONTENT_TYPE_CBOR, CONTENT_TYPE_JSON, + }, +}; +use smallvec::SmallVec; +use std::{ + borrow::Cow, + collections::HashMap, + convert::Infallible, + io::SeekFrom, + net::SocketAddr, + path::{Path, PathBuf}, + sync::{Arc, LazyLock}, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncSeekExt}, + sync::RwLock, +}; +use tokio_util::io::ReaderStream; +use tuliprox_hls::api::MAX_HLS_MANIFEST_BYTES; +use url::Url; + +pub(crate) fn resolve_request_url_for_logging<'a>(input: &ConfigInput, stream_url: &'a str) -> Cow<'a, str> { + if is_media_server_playback_url(input, stream_url) { + return Cow::Borrowed("media-server://"); + } + if is_sanitize_sensitive_info_enabled() { + return Cow::Borrowed(stream_url); + } + + let provider = input.get_resolve_provider(stream_url); + if let Ok(url) = Url::parse(stream_url) { + return Cow::Owned(request::preview_request_target_for_logging(&url, provider.as_ref())); + } + + input + .resolve_url(stream_url) + .ok() + .and_then(|resolved| { + Url::parse(resolved.as_ref()) + .ok() + .map(|url| Cow::Owned(request::preview_request_target_for_logging(&url, provider.as_ref()))) + }) + .unwrap_or(Cow::Borrowed(stream_url)) +} + +pub(crate) struct ConnectFailedAttempt<'a> { + pub app_state: &'a Arc, + pub fingerprint: &'a Fingerprint, + pub user: &'a ProxyUserCredentials, + pub stream_channel: StreamChannel, + pub provider_name: Arc, + pub req_headers: &'a HeaderMap, + pub reason: ConnectFailureReason, + pub failure_stage: FailureStage, +} + +pub(crate) fn record_connect_failed_attempt(attempt: ConnectFailedAttempt<'_>) { + let user_agent = attempt + .req_headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let info = StreamInfo::new(shared::model::StreamInfoParams { + uid: 0, + meter_uid: 0, + username: &attempt.user.username, + addr: &attempt.fingerprint.addr, + client_ip: &attempt.fingerprint.client_ip, + provider: attempt.provider_name, + stream_channel: attempt.stream_channel, + user_agent, + country_code: None, + session_token: None, + }); + // Resolve target_name from target_id using the stable target config name. + let target_name = + attempt.app_state.app_config.get_target_by_id(info.channel.target_id).as_deref().map(|t| (&t.name).intern()); + attempt.app_state.connection_manager.record_connect_failed_with_provider_failure( + &info, + attempt.reason, + attempt.failure_stage, + None, + None, + target_name, + ); +} + +fn admission_failure_video_type(reason: ConnectFailureReason) -> Option { + match reason { + ConnectFailureReason::UserAccountExpired => Some(CustomVideoStreamType::UserAccountExpired), + ConnectFailureReason::UserConnectionsExhausted => Some(CustomVideoStreamType::UserConnectionsExhausted), + ConnectFailureReason::ProviderConnectionsExhausted => Some(CustomVideoStreamType::ProviderConnectionsExhausted), + _ => None, + } +} + +pub(crate) fn admission_failure_response( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + stream_channel: StreamChannel, + provider_name: Arc, + req_headers: &HeaderMap, + reason: ConnectFailureReason, +) -> axum::response::Response { + record_connect_failed_attempt(ConnectFailedAttempt { + app_state, + fingerprint, + user, + stream_channel, + provider_name, + req_headers, + reason, + failure_stage: FailureStage::Admission, + }); + let Some(video_type) = admission_failure_video_type(reason) else { + error!("Unsupported admission failure reason: {reason:?}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + create_custom_video_stream_response(&app_state.provider_stream_ctx(), &fingerprint.addr, video_type).into_response() +} + +#[macro_export] +macro_rules! try_option_bad_request { + ($option:expr, $msg_is_error:expr, $msg:expr) => { + match $option { + Some(value) => value, + None => { + if $msg_is_error { + error!("{}", $msg); + } else { + debug!("{}", $msg); + } + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + } + }; + ($option:expr) => { + match $option { + Some(value) => value, + None => return axum::http::StatusCode::BAD_REQUEST.into_response(), + } + }; +} + +#[macro_export] +macro_rules! try_option_forbidden { + ($option:expr, $status:expr, $msg_is_error:expr, $msg:expr) => { + match $option { + Some(value) => value, + None => { + if $msg_is_error { + error!("{}", $msg); + } else { + debug!("{}", $msg); + } + return $status.into_response(); + } + } + }; + ($option:expr, $msg_is_error:expr, $msg:expr) => { + match $option { + Some(value) => value, + None => { + if $msg_is_error { + error!("{}", $msg); + } else { + debug!("{}", $msg); + } + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + } + }; + ($option:expr) => { + match $option { + Some(value) => value, + None => return axum::http::StatusCode::FORBIDDEN.into_response(), + } + }; +} + +#[macro_export] +macro_rules! internal_server_error { + () => { + axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + }; +} + +#[macro_export] +macro_rules! try_result_or_status { + ($option:expr, $status:expr, $msg_is_error:expr, $msg:expr) => { + match $option { + Ok(value) => value, + Err(_) => { + if $msg_is_error { + error!("{}", $msg); + } else { + debug!("{}", $msg); + } + return $status.into_response(); + } + } + }; + ($option:expr, $status:expr) => { + match $option { + Ok(value) => value, + Err(_) => return $status.into_response(), + } + }; +} + +#[macro_export] +macro_rules! try_result_bad_request { + ($option:expr, $msg_is_error:expr, $msg:expr) => { + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST, $msg_is_error, $msg) + }; + ($option:expr) => { + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST) + }; +} + +#[macro_export] +macro_rules! try_result_not_found { + ($option:expr, $msg_is_error:expr, $msg:expr) => { + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND, $msg_is_error, $msg) + }; + ($option:expr) => { + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND) + }; +} + +use crate::{ + api::{ + panel_api::{can_provision_on_exhausted, create_panel_api_provisioning_stream_details}, + static_headers::CT_OCTET, + }, + utils::LRUResourceCache, +}; +pub use internal_server_error; +use shared::{ + defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs}, + error::TuliproxError, + model::{ConnectFailureReason, FailureStage}, +}; +pub use try_option_bad_request; +pub use try_option_forbidden; +pub use try_result_bad_request; +pub use try_result_not_found; +pub use try_result_or_status; +// Moved to `tuliprox-core` so crates outside `api` can build responses too. +pub use tuliprox_core::try_unwrap_body; +// Admission moved to `tuliprox-session`, where the types it decides over +// already live. Re-exported so api call sites keep their names. +pub(crate) use tuliprox_core::utils::request_headers::{get_headers_from_request, HeaderFilter}; +pub(crate) use tuliprox_session::{ + admission::{ + classify_playback_request, connection_priority_for_kind, resolve_admission_with_strategies, + resolve_playback_request_admission, AdmissionRequest, EvictionReentryGuard, PlaybackRequestClass, + PlaybackRequestFacts, + }, + stream_options::{get_stream_options, StreamOptions}, +}; + +pub fn get_server_time() -> String { + chrono::offset::Local::now().with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %Z").to_string() +} + +static PROCESS_START: LazyLock = LazyLock::new(std::time::Instant::now); + +/// Anchors the uptime clock; call once at process startup. +pub fn init_uptime_clock() { let _ = *PROCESS_START; } + +pub fn get_uptime_secs() -> u64 { PROCESS_START.elapsed().as_secs() } + +pub fn get_build_time() -> Option { + BUILD_TIMESTAMP + .to_string() + .parse::>() + .ok() + .map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string()) +} + +// Response-compression opt-out moved to `tuliprox_core::utils`; re-exported so +// api call sites keep their names. +pub(crate) use tuliprox_core::utils::response_compression::{ + mark_response_as_uncompressed, should_compress_response_extensions, +}; + +#[derive(Clone, Copy, Debug, Default)] +struct StreamMeteringConfig { + meter_uid: u32, + meter_stream: bool, +} + +#[allow(clippy::missing_panics_doc)] +pub async fn serve_file(file_path: &Path, mime_type: String, cache_control: Option<&str>) -> impl IntoResponse + Send { + match tokio::fs::try_exists(file_path).await { + Ok(exists) => { + if !exists { + return StatusCode::NOT_FOUND.into_response(); + } + } + Err(err) => { + error!("Failed to open file {}, {err:?}", file_path.display()); + return StatusCode::NOT_FOUND.into_response(); + } + } + + match tokio::fs::File::open(file_path).await { + Ok(file) => { + let last_modified = file.metadata().await.ok().and_then(|m| m.modified().ok()).map(|m| { + let dt: DateTime = m.into(); + dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() + }); + + let reader = async_file_reader(file); + let stream = ReaderStream::new(reader); + let body = Body::from_stream(stream); + + let mut builder = axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type) + .header(header::CACHE_CONTROL, cache_control.unwrap_or("no-cache")); + + if let Some(lm) = last_modified { + builder = builder.header(header::LAST_MODIFIED, lm); + } + + try_unwrap_body!(builder.body(body)) + } + Err(_) => internal_server_error!(), + } +} + +pub fn get_user_target_by_username( + username: &str, + app_state: &Arc, +) -> Option<(Arc, Arc)> { + if !username.is_empty() { + return app_state.app_config.get_target_for_username(username); + } + None +} + +pub fn get_user_target_by_credentials<'a>( + username: &str, + password: &str, + api_req: &'a UserApiRequest, + app_state: &'a AppState, +) -> Option<(Arc, Arc)> { + if !username.is_empty() && !password.is_empty() { + app_state.app_config.get_target_for_user(username, password) + } else { + let token = api_req.token.as_str().trim(); + if token.is_empty() { + None + } else { + app_state.app_config.get_target_for_user_by_token(token) + } + } +} + +pub fn get_user_target<'a>( + api_req: &'a UserApiRequest, + app_state: &'a AppState, +) -> Option<(Arc, Arc)> { + let username = api_req.username.as_str().trim(); + let password = api_req.password.as_str().trim(); + get_user_target_by_credentials(username, password, api_req, app_state) +} + +struct StreamingAcquireOptions<'a> { + force_provider: Option<&'a Arc>, + allow_forced_provider_fallback: bool, + allow_provider_grace: bool, + user_priority: i8, + connection_kind: crate::api::model::ConnectionKind, + session_owner: Option<&'a str>, + accept_requested_stream_url: bool, +} + +pub struct ForceStreamRequestContext<'a> { + pub req_headers: &'a HeaderMap, + pub input: &'a Arc, + pub user: &'a ProxyUserCredentials, + pub session_reservation_ttl_secs: u64, + pub(crate) content_representation: crate::api::model::ProviderContentRepresentationMode, +} + +struct SessionActivationRequest<'a> { + fingerprint: &'a Fingerprint, + input: &'a ConfigInput, + user: &'a ProxyUserCredentials, + session_token: &'a str, + request_class: Option, + virtual_id: VirtualId, + item_type: PlaylistItemType, + stream_url: &'a str, + connection_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + socket_bound: bool, +} + +struct PlaybackActivationResult { + admission: crate::api::model::ConnectionAdmission, + grace_mode: Option, + grace_context: Option, + placeholder_transition_version: Option, +} + +/// # Panics +#[allow(clippy::too_many_lines)] +async fn activate_session_before_stream_open( + app_state: &Arc, + request: SessionActivationRequest<'_>, +) -> PlaybackActivationResult { + let SessionActivationRequest { + fingerprint, + input, + user, + session_token, + request_class, + virtual_id, + item_type, + stream_url, + connection_permission, + connection_kind, + socket_bound, + } = request; + // Classify based on current session state, not the pre-computed value. + // 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 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).await; + classify_playback_request(PlaybackRequestFacts { + existing_session: current_session.as_ref(), + prepare_only: false, + terminate: false, + }) + } else { + request_class + } + } else { + let existing_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; + classify_playback_request(PlaybackRequestFacts { + existing_session: existing_session.as_ref(), + prepare_only: false, + terminate: false, + }) + }; + let limits_enabled = app_state.app_config.config.load().user_access_control + && (user.max_connections > 0 || user.soft_connections > 0); + // Prepare: session setup without admission cost. The caller handles the actual activation. + // FollowUp: already counted, no re-admission needed. + // GracePeriod: grace already granted, no re-evaluation needed. + // No limits: skip admission entirely. + // GracePeriod permission is already resolved — skip admission strategies (re-run + // would evict the same session again). But we must still materialize the grace + // lifecycle (PendingProvider / GraceActive) so the session state is consistent. + if connection_permission == UserConnectionPermission::GracePeriod { + // Materialize grace lifecycle under the guard so the session state is consistent. + // Determine which grace mode applies by checking the current session state. + let current_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; + let (_, resolved_grace) = match current_session.as_ref().map(|s| &s.lifecycle) { + Some(crate::api::model::PlaybackLifecycle::PendingProvider { .. }) => { + // Session already in PendingProvider — refresh deadline. + let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); + let _ = app_state + .active_users + .mark_pending_provider(&user.username, session_token, PendingProviderReason::GraceHold, deadline) + .await; + ( + crate::api::model::PlaybackLifecycle::PendingProvider { + data: crate::api::model::PendingProviderState { + reason_code: PendingProviderReason::GraceHold, + created_at: current_time_secs(), + deadline, + version: current_session.as_ref().map_or(0, |s| { + if let crate::api::model::PlaybackLifecycle::PendingProvider { data } = &s.lifecycle { + data.version + } else { + 0 + } + }), + wake_source: None, + }, + }, + Some(crate::api::model::GraceMode::Hold), + ) + } + Some(crate::api::model::PlaybackLifecycle::GraceActive) => { + // 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. + // Live/LiveHls/LiveDash default to Hold; VOD/Catchup to Instant. + if item_type.is_live() || item_type.is_live_adaptive() { + let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); + let _ = app_state + .active_users + .mark_pending_provider( + &user.username, + session_token, + PendingProviderReason::GraceHold, + deadline, + ) + .await; + ( + crate::api::model::PlaybackLifecycle::PendingProvider { + data: crate::api::model::PendingProviderState { + reason_code: PendingProviderReason::GraceHold, + created_at: current_time_secs(), + deadline, + version: 1, + wake_source: None, + }, + }, + Some(crate::api::model::GraceMode::Hold), + ) + } else { + app_state.active_users.mark_grace_active(&user.username, session_token).await; + (crate::api::model::PlaybackLifecycle::GraceActive, Some(crate::api::model::GraceMode::Instant)) + } + } + }; + return PlaybackActivationResult { + admission: crate::api::model::ConnectionAdmission { + permission: connection_permission, + kind: Some(connection_kind), + }, + grace_mode: resolved_grace, + grace_context: None, + placeholder_transition_version: None, + }; + } + // No limits: skip admission entirely. FollowUp / Prepare: no re-admission needed. + if !limits_enabled + || effective_request_class == PlaybackRequestClass::FollowUp + || effective_request_class == PlaybackRequestClass::Prepare + { + return PlaybackActivationResult { + admission: crate::api::model::ConnectionAdmission { + permission: connection_permission, + kind: Some(connection_kind), + }, + grace_mode: None, + grace_context: None, + placeholder_transition_version: None, + }; + } + + let placeholder_transition_version = Some( + app_state + .active_users + .ensure_user_session_placeholder(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id: virtual_id.get(), + provider: input.name.as_ref(), + stream_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(connection_kind), + socket_bound, + }) + .await, + ); + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some(session_token), + activate_unbound_session: true, + eviction_reentry_guard: if socket_bound { + EvictionReentryGuard::SocketPlayback { virtual_id } + } else { + EvictionReentryGuard::Session(session_token) + }, + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + let grace_context = result.grace_context; + + if admission.permission == UserConnectionPermission::GracePeriod { + if matches!(grace_mode, Some(crate::api::model::GraceMode::Hold)) { + // Hold: session waits for provider slot. Does not count until provider is acquired. + let deadline = current_time_secs().saturating_add(app_state.get_grace_options().timeout_secs); + let _ = app_state + .active_users + .mark_pending_provider(&user.username, session_token, PendingProviderReason::GraceHold, deadline) + .await; + } else if matches!(grace_mode, Some(crate::api::model::GraceMode::Instant)) { + // Instant: session is provisionally active immediately. Counts against admission limits + // until the grace window resolves (success -> Active, failure -> Expired). + app_state.active_users.mark_grace_active(&user.username, session_token).await; + } + } + + PlaybackActivationResult { admission, grace_mode, grace_context, placeholder_transition_version } +} + +pub fn get_stream_alternative_url( + stream_url: &str, + input: &ConfigInput, + alias_input: &Arc, +) -> Option { + if input.input_type.is_m3u() && input.get_matched_config_by_url(stream_url).is_none() { + return get_stream_alternative_url_m3u(stream_url, input, alias_input); + } + + let (source_base_url, source_username, source_password, matched_via_external_signature) = + if let Some(matched) = input.get_matched_config_by_url(stream_url) { + (matched.0.to_string(), matched.1.cloned(), matched.2.cloned(), false) + } else { + let (base_url, username, password) = find_input_account_by_signature(stream_url, input)?; + (base_url, username, password, true) + }; + if matched_via_external_signature && !input.input_type.is_m3u() { + return None; + } + 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()?; + + if let (Some(old_username), Some(old_password)) = (source_username, source_password) { + let auth_updated = rewrite_url_auth_fields( + &mut url, + &old_username, + &old_password, + &alt_input_user_info.username, + &alt_input_user_info.password, + ); + if !auth_updated { + return None; + } + } + + Some(url.to_string()) +} + +fn get_stream_alternative_url_m3u( + stream_url: &str, + input: &ConfigInput, + alias_input: &Arc, +) -> Option { + if let Some((source_base_url, source_username, source_password)) = + find_input_account_by_signature(stream_url, input) + { + let Some(alt_input_user_info) = alias_input.get_user_info() else { + return Some(stream_url.to_string()); + }; + let modified = stream_url.replacen(&source_base_url, &alt_input_user_info.base_url, 1); + let mut url = Url::parse(&modified).ok()?; + + if let (Some(old_username), Some(old_password)) = (source_username, source_password) { + let auth_updated = rewrite_url_auth_fields( + &mut url, + &old_username, + &old_password, + &alt_input_user_info.username, + &alt_input_user_info.password, + ); + if !auth_updated { + return None; + } + } + + return Some(url.to_string()); + } + let Some(alt_input_user_info) = alias_input.get_user_info() else { + let Ok(url) = Url::parse(stream_url) else { + return None; + }; + if providerless_m3u_url_has_explicit_credentials(&url) { + return None; + } + return Some(stream_url.to_string()); + }; + if stream_url_has_account_signature(stream_url, &alt_input_user_info) { + return None; + } + Some(stream_url.to_string()) +} + +fn providerless_m3u_url_has_explicit_credentials(url: &Url) -> bool { + !url.username().is_empty() + || url.password().is_some() + || url + .query_pairs() + .any(|(key, _)| key.eq_ignore_ascii_case("username") || key.eq_ignore_ascii_case("password")) +} + +/// Look for an account signature in the stream URL that matches the input +/// itself or one of its configured aliases. Returns the matching entry's +/// `(base_url, username, password)` so the caller can rewrite only the +/// account-specific parts of the URL while preserving the original host/path. +/// +/// This helper is used for safe credential rewrites when Tuliprox switches +/// from one account to another. It is not the general trust gate for M3U +/// foreign hosts: plain external URLs from a stored M3U playlist item may be +/// accepted without a matching signature, while unrelated credential-bearing +/// URLs still fail closed unless they provably match the input or one of its +/// aliases. +fn find_input_account_by_signature( + stream_url: &str, + input: &ConfigInput, +) -> Option<(String, Option, Option)> { + // Try the input's main account first. + if let Some(user_info) = input.get_user_info() { + if stream_url_account_matches(stream_url, &user_info) { + return Some((input.url.clone(), Some(user_info.username), Some(user_info.password))); + } + } + // Then try each alias, if any. The input_type is inherited from the + // parent input for all aliases — see ConfigInputAlias definition. + if let Some(aliases) = input.aliases.as_ref() { + for alias in aliases { + if let Some(user_info) = + InputUserInfo::new(input.input_type, alias.username.as_deref(), alias.password.as_deref(), &alias.url) + { + if stream_url_account_matches(stream_url, &user_info) { + return Some((alias.url.clone(), Some(user_info.username), Some(user_info.password))); + } + } + } + } + None +} + +fn rewrite_url_auth_fields( + url: &mut Url, + old_username: &str, + old_password: &str, + new_username: &str, + new_password: &str, +) -> bool { + if rewrite_query_auth_fields(url, new_username, new_password) { + return true; + } + + if url.username() == old_username && url.password() == Some(old_password) { + return url.set_username(new_username).is_ok() && url.set_password(Some(new_password)).is_ok(); + } + + rewrite_path_auth_fields(url, old_username, old_password, new_username, new_password) +} + +fn rewrite_query_auth_fields(url: &mut Url, new_username: &str, new_password: &str) -> bool { + let mut has_username = false; + let mut has_password = false; + let pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(key, value)| { + if key.eq_ignore_ascii_case("username") { + has_username = true; + (key.into_owned(), new_username.to_string()) + } else if key.eq_ignore_ascii_case("password") { + has_password = true; + (key.into_owned(), new_password.to_string()) + } else { + (key.into_owned(), value.into_owned()) + } + }) + .collect(); + + if !(has_username && has_password) { + return false; + } + + url.query_pairs_mut().clear().extend_pairs(pairs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + true +} + +fn collect_path_segments(url: &Url) -> Option> { + url.path_segments().map(|segments| segments.map(ToOwned::to_owned).collect::>()) +} + +fn find_path_auth_segment_index(segments: &[String], username: &str, password: &str) -> Option { + 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, + old_password: &str, + new_username: &str, + new_password: &str, +) -> bool { + let Some(mut segments) = collect_path_segments(url) else { + return false; + }; + + let credential_index = find_path_auth_segment_index(&segments, old_username, old_password); + let Some(credential_index) = credential_index else { + return false; + }; + + segments[credential_index] = new_username.to_string(); + segments[credential_index + 1] = new_password.to_string(); + + let Ok(mut path_segments) = url.path_segments_mut() else { + return false; + }; + path_segments.clear().extend(segments.iter().map(String::as_str)); + true +} + +fn stream_url_matches_provider(stream_url: &str, provider_cfg: &ProviderConfig) -> bool { + let Some(user_info) = provider_cfg.get_user_info() else { + return false; + }; + if stream_url_base_matches(stream_url, &user_info.base_url) { + // Same-host fast path: both base URL and account identity must match. + return stream_url_account_matches(stream_url, &user_info); + } + if !provider_cfg.input_type.is_m3u() { + return false; + } + // 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, &user_info) { + return stream_url_account_matches(stream_url, &user_info); + } + true +} + +fn stream_url_base_matches(stream_url: &str, base_url: &str) -> bool { + stream_url + .strip_prefix(base_url) + .is_some_and(|remaining| remaining.is_empty() || remaining.starts_with(['/', '?', '#'])) +} + +fn stream_url_account_matches(stream_url: &str, user_info: &crate::model::InputUserInfo) -> bool { + let Ok(url) = Url::parse(stream_url) else { + return false; + }; + + let (url_username, url_password) = get_credentials_from_url(&url); + if let (Some(url_username), Some(url_password)) = (url_username.as_deref(), url_password.as_deref()) { + return url_username == user_info.username && url_password == user_info.password; + } + + let mut has_query_username = false; + let mut has_query_password = false; + for (key, value) in url.query_pairs() { + if key.eq_ignore_ascii_case("username") { + has_query_username = value == user_info.username; + } else if key.eq_ignore_ascii_case("password") { + has_query_password = value == user_info.password; + } + } + if has_query_username || has_query_password { + return has_query_username && has_query_password; + } + + let Some(segments) = collect_path_segments(&url) else { + return false; + }; + + find_path_auth_segment_index(&segments, &user_info.username, &user_info.password).is_some() +} + +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; + }; + + let (url_username, url_password) = get_credentials_from_url(&url); + if url_username.is_some() && url_password.is_some() { + return true; + } + + let mut has_query_username = false; + let mut has_query_password = false; + for (key, _) in url.query_pairs() { + if key.eq_ignore_ascii_case("username") { + has_query_username = true; + } else if key.eq_ignore_ascii_case("password") { + has_query_password = true; + } + } + 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///...). 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 +} + +fn select_provider_stream_url( + stream_url: &str, + input: &ConfigInput, + provider_cfg: &Arc, + accept_requested_stream_url: bool, +) -> Option<(Arc, 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 { + get_stream_alternative_url(stream_url, input, provider_cfg).map(|url| (provider_cfg.name.clone(), url)) + } +} + +fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState { + ProviderStreamState::Custom { + response: create_channel_unavailable_stream(app_config, &[], StatusCode::OK), + reason: ProviderStreamCustomReason::UnmappedProviderUrl, + } +} + +async fn acquire_stream_provider_handle( + app_state: &Arc, + input: &ConfigInput, + fingerprint: &Fingerprint, + options: StreamingAcquireOptions<'_>, +) -> Option { + match options.force_provider { + Some(provider) => { + // First try to stay on the exact pinned provider account without over-allocating. + if let Some(handle) = app_state + .active_provider + .acquire_exact_connection_with_grace_for_session( + provider, + &fingerprint.addr, + options.allow_provider_grace, + options.user_priority, + options.connection_kind, + options.session_owner, + ) + .await + { + Some(handle) + } else if options.allow_forced_provider_fallback { + debug_if_enabled!( + "Pinned provider {} unavailable for {}; falling back to lineup allocation", + sanitize_sensitive_info(provider), + sanitize_sensitive_info(&fingerprint.addr.to_string()) + ); + app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &fingerprint.addr, + options.allow_provider_grace, + options.user_priority, + options.connection_kind, + options.session_owner, + ) + .await + } else { + debug_if_enabled!( + "Pinned provider {} unavailable for {}; strict provider affinity prevents fallback", + sanitize_sensitive_info(provider), + sanitize_sensitive_info(&fingerprint.addr.to_string()) + ); + None + } + } + None => { + app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &fingerprint.addr, + options.allow_provider_grace, + options.user_priority, + options.connection_kind, + options.session_owner, + ) + .await + } + } +} + +pub(crate) fn resolve_redirect_location<'a>( + input: Option<&ConfigInput>, + stream_url: &'a str, +) -> Result, TuliproxError> { + input.map_or(Ok(Cow::Borrowed(stream_url)), |input| input.resolve_url(stream_url)) +} + +async fn get_redirect_alternative_url( + app_state: &Arc, + redirect_url: &Arc, + input: &ConfigInput, +) -> Arc { + if let Some((base_url, username, password)) = input.get_matched_config_by_url(redirect_url) { + if let Some(provider_cfg) = app_state.active_provider.get_next_provider(&input.name).await { + let mut new_url = redirect_url.replacen(base_url, provider_cfg.url.as_str(), 1); + if let (Some(old_username), Some(old_password)) = (username, password) { + if let (Some(new_username), Some(new_password)) = + (provider_cfg.username.as_ref(), provider_cfg.password.as_ref()) + { + new_url = new_url.replacen(old_username, new_username, 1); + new_url = new_url.replacen(old_password, new_password, 1); + return new_url.into(); + } + // one has credentials the other not, something not right + return redirect_url.clone(); + } + return new_url.into(); + } + } + redirect_url.clone() +} + +/// Determines the appropriate streaming strategy for the given input and stream URL. +/// +/// This function attempts to acquire a connection to a streaming provider, either using a forced provider +/// (if specified), or based on the input name. It then selects a corresponding `StreamingOption`: +/// +/// - If no connections are available (`Exhausted`), it returns a custom stream indicating exhaustion. +/// - If a connection is available or in a grace period, it constructs a streaming URL accordingly: +/// - If the URL already targets the selected provider account, the original URL is reused. +/// - Otherwise, an alternative URL is generated based on the provider and input. +/// +/// The function returns: +/// - an optional `ProviderConnectionGuard` to manage the connection's lifecycle, +/// - a `ProviderStreamState` describing how the stream state is, +/// - and optional HTTP headers to include in the request. +/// +/// This logic helps abstract the decision-making behind provider selection and stream URL resolution. +async fn resolve_streaming_strategy( + app_state: &Arc, + stream_url: &str, + fingerprint: &Fingerprint, + input: &ConfigInput, + options: StreamingAcquireOptions<'_>, +) -> StreamingStrategy { + // allocate a provider connection + let accept_requested_stream_url = options.accept_requested_stream_url || input.input_type.is_stalker(); + 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 + + let mut release_failed_mapping = false; + let stream_response_params = if let Some(allocation) = provider_connection_handle.as_ref().map(|ph| &ph.allocation) + { + match allocation { + ProviderAllocation::Exhausted => { + debug!("Provider {} is exhausted. No connections allowed.", input.name); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom { response: stream, reason: ProviderStreamCustomReason::ProviderExhausted } + } + 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, accept_requested_stream_url) + { + debug_if_enabled!( + "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info( + provider_cfg.get_user_info().as_ref().map_or_else(|| "?", |u| u.username.as_str()) + ), + allocation.short_key(), + sanitize_sensitive_info(resolve_request_url_for_logging(input, &url).as_ref()) + ); + + if matches!(allocation, ProviderAllocation::Available(_)) { + ProviderStreamState::Available(Some(selected_provider_name.intern()), url.intern()) + } else { + ProviderStreamState::GracePeriod(Some(selected_provider_name.intern()), url.intern()) + } + } else { + debug_if_enabled!( + "provider session rejected: input={} provider_cfg={} allocation={} stream_url={} reason=unmapped_provider_url", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(&provider_cfg.name), + allocation.short_key(), + sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()) + ); + release_failed_mapping = true; + create_unmapped_provider_stream(&app_state.app_config) + } + } + } + } else { + debug!("Provider {} is exhausted. No connections allowed.", input.name); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom { response: stream, reason: ProviderStreamCustomReason::ProviderExhausted } + }; + + if release_failed_mapping { + if let Some(handle) = provider_connection_handle.take() { + let connection_manager = Arc::clone(&app_state.connection_manager); + tokio::spawn(async move { + connection_manager.release_provider_handle(Some(handle)).await; + }); + } + } + + StreamingStrategy { + provider_handle: provider_connection_handle, + provider_stream_state: stream_response_params, + input_headers: Some(input.headers.clone()), + } +} + +fn get_grace_period_millis( + connection_permission: UserConnectionPermission, + stream_response_params: &ProviderStreamState, + config_grace_period_millis: u64, +) -> u64 { + if config_grace_period_millis > 0 + && ( + matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period + || connection_permission == UserConnectionPermission::GracePeriod + // user grace period + ) + { + config_grace_period_millis + } else { + 0 + } +} + +fn should_defer_provider_open_for_grace_hold( + provider_grace_active: bool, + hold_stream: bool, + item_type: PlaylistItemType, + is_reopen: bool, +) -> bool { + if !(provider_grace_active && hold_stream) { + return false; + } + + // Catch-up must open immediately so its payload can be classified before response headers are committed. + if item_type == PlaylistItemType::Catchup { + return false; + } + + // v3.3.0 opened provider-affine VOD/Series reopens immediately, even when + // provider grace was temporarily in effect. Parking these requests in GracePending + // was introduced later and breaks players like libmpv during seek/reopen retries. + // Keep hold-stream behavior for live/admission paths, but restore direct-open behavior + // for provider-affine on-demand session reopens. + !(!item_type.is_live() && item_type.requires_provider_affinity() && is_reopen) +} + +fn should_refresh_stalker_playback(input_type: InputType, request_url_valid: bool, status: Option) -> bool { + input_type.is_stalker() && (!request_url_valid || status.is_some_and(|status| status.is_client_error())) +} + +fn needs_initial_stalker_resolution(input_type: InputType, stream_url: &str) -> bool { + input_type.is_stalker() && stream_url.is_empty() +} + +fn stalker_stream_kind(cluster: XtreamCluster, item_type: PlaylistItemType) -> StalkerStreamKind { + if item_type == PlaylistItemType::Catchup { + StalkerStreamKind::Archive + } else { + match cluster { + XtreamCluster::Live => StalkerStreamKind::Live, + XtreamCluster::Video => StalkerStreamKind::Movie, + XtreamCluster::Series => StalkerStreamKind::Episode, + } + } +} + +async fn re_resolve_stalker_url_singleflight( + app_state: &Arc, + input: &ConfigInput, + provider_id: u32, + kind: StalkerStreamKind, + force_refresh: bool, +) -> Result>, TuliproxError> { + let entry_lock = app_state.stalker_resolve_coordinator.guard_for(input.id, provider_id).await; + let _flight = entry_lock.lock().await; + let client = app_state.http_client.load().as_ref().clone(); + re_resolve_stalker_url(&app_state.app_config, &client, input, provider_id, kind, force_refresh).await +} + +pub(crate) async fn resolve_initial_stalker_playback_url( + app_state: &Arc, + input: &ConfigInput, + provider_id: u32, + cluster: XtreamCluster, + item_type: PlaylistItemType, + stream_url: &Arc, +) -> Result, TuliproxError> { + if !needs_initial_stalker_resolution(input.input_type, stream_url) { + return Ok(Arc::clone(stream_url)); + } + re_resolve_stalker_url_singleflight(app_state, input, provider_id, stalker_stream_kind(cluster, item_type), false) + .await? + .ok_or_else(|| { + TuliproxError::RepositoryStalker(format!( + "Stalker playback URL could not be resolved for input '{}' and provider id {provider_id}", + input.name + )) + }) +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)] +async fn create_stream_response_details( + app_state: &Arc, + stream_options: &StreamOptions, + stream_url: &str, + username: &str, + fingerprint: &Fingerprint, + req_headers: &HeaderMap, + input: &Arc, + stream_channel: &StreamChannel, + item_type: PlaylistItemType, + content_representation: crate::api::model::ProviderContentRepresentationMode, + share_stream: bool, + connection_permission: UserConnectionPermission, + force_provider: Option<&Arc>, + allow_forced_provider_fallback: bool, + allow_provider_grace: bool, + virtual_id: VirtualId, + user_priority: i8, + connection_kind: crate::api::model::ConnectionKind, + is_reopen: bool, + session_owner: Option<&str>, + session_headers: Option<&HashMap>, + accept_requested_stream_url: bool, + grace_hold_override: Option, + grace_resolution_context: Option, +) -> Result { + let mut streaming_strategy = resolve_streaming_strategy( + app_state, + stream_url, + fingerprint, + input, + StreamingAcquireOptions { + force_provider, + allow_forced_provider_fallback, + allow_provider_grace, + user_priority, + connection_kind, + session_owner, + accept_requested_stream_url, + }, + ) + .await; + let mut grace_period_options = app_state.get_grace_options(); + grace_period_options.period_millis = get_grace_period_millis( + connection_permission, + &streaming_strategy.provider_stream_state, + grace_period_options.period_millis, + ); + if let Some(hold) = grace_hold_override { + grace_period_options.hold_stream = hold; + } + let provider_grace_active = + matches!(streaming_strategy.provider_stream_state, ProviderStreamState::GracePeriod(_, _)); + + let guard_provider_name = + streaming_strategy.provider_handle.as_ref().and_then(|guard| guard.allocation.get_provider_name()); + + if matches!( + streaming_strategy.provider_stream_state, + ProviderStreamState::Custom { reason: ProviderStreamCustomReason::ProviderExhausted, .. } + ) && can_provision_on_exhausted(app_state, input) + { + if let Some(handle) = streaming_strategy.provider_handle.take() { + app_state.connection_manager.release_provider_handle(Some(handle)).await; + } + debug_if_enabled!( + "panel_api: provider connections exhausted; sending provisioning stream for input {}", + sanitize_sensitive_info(&input.name) + ); + let mut details = create_panel_api_provisioning_stream_details( + app_state, + input, + guard_provider_name.clone().or_else(|| Some(input.name.clone())), + &grace_period_options, + fingerprint.addr, + virtual_id, + ); + details.content_representation = content_representation; + return Ok(details); + } + + match streaming_strategy.provider_stream_state { + // custom stream means we display our own stream like connection exhausted, channel-unavailable... + ProviderStreamState::Custom { response: provider_stream, .. } => { + let (stream, stream_info) = provider_stream; + // When allocation is exhausted or no connection was acquired, guard_provider_name is None. + // Use input.name as fallback so the provider field is never empty. + let provider_name = guard_provider_name.clone().unwrap_or_else(|| input.name.clone()); + Ok(StreamDetails { + stream, + stream_info, + provider_name: Some(provider_name), + request_url: None, + session_headers: session_headers.cloned(), + provider_session_headers: HashMap::new(), + grace_period: grace_period_options, + provider_grace_active: false, + disable_provider_grace: false, + reconnect_flag: None, + provider_handle: streaming_strategy.provider_handle.clone(), + content_representation, + grace_resolution_context, + }) + } + ProviderStreamState::Available(_provider_name, request_url) + | ProviderStreamState::GracePeriod(_provider_name, request_url) => { + let mut request_url = request_url; + debug_if_enabled!( + "Provider stream selection: allocated_provider={} actual_request_url={}", + sanitize_sensitive_info(guard_provider_name.as_deref().unwrap_or("?")), + sanitize_sensitive_info(resolve_request_url_for_logging(input, request_url.as_ref()).as_ref()) + ); + let defer_provider_stream_until_grace_check = if should_defer_provider_open_for_grace_hold( + provider_grace_active, + grace_period_options.hold_stream, + item_type, + is_reopen, + ) { + if let Some(provider_name) = guard_provider_name.as_ref() { + app_state.active_provider.is_over_limit(provider_name).await + } else { + false + } + } else { + false + }; + let (stream, stream_info, provider_session_headers, reconnect_flag) = + if defer_provider_stream_until_grace_check { + debug_if_enabled!( + "Deferring provider stream open until grace check completes for {}", + sanitize_sensitive_info(resolve_request_url_for_logging(input, request_url.as_ref()).as_ref()) + ); + (None, None, HashMap::new(), None) + } else if is_media_server_stream_ref_url(request_url.as_ref()) { + match open_media_server_stream_for_input(app_state, input, request_url.as_ref(), req_headers).await + { + Ok((stream, stream_info)) => (Some(stream), stream_info, HashMap::new(), None), + Err(err) => { + error!("Can't open media-server stream: {err}"); + (None, None, HashMap::new(), None) + } + } + } else { + let parsed_url = Url::parse(&request_url); + let request_url_valid = parsed_url.is_ok(); + let ((mut stream, mut stream_info, mut provider_session_headers), mut reconnect_flag) = + if let Ok(url) = parsed_url { + let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); + let disabled_headers = app_state.get_disabled_headers(); + let mut provider_stream_factory_options = + ProviderStreamFactoryOptions::new(&crate::api::model::ProviderStreamFactoryParams { + addr: fingerprint.addr, + item_type, + share_stream, + stream_options, + 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), + client_ip: Some(&fingerprint.client_ip), + stream_channel: Some(stream_channel), + connect_failure_stage: Some(FailureStage::ProviderOpen), + content_representation, + }); + + let provider_config = input.get_resolve_provider(url.as_ref()); + provider_stream_factory_options.set_provider(provider_config); + if input.input_type.is_stalker() { + provider_stream_factory_options.require_public_destination(); + } + + let reconnect_flag = provider_stream_factory_options.get_reconnect_flag_clone(); + let provider_stream = match create_provider_stream( + &app_state.provider_stream_ctx(), + &app_state.http_client.load(), + provider_stream_factory_options, + ) + .await + { + None => (None, None, HashMap::new()), + Some(response) => { + (Some(response.stream), response.info, response.provider_session_headers) + } + }; + (provider_stream, Some(reconnect_flag)) + } else { + ((None, None, HashMap::new()), None) + }; + let should_refresh_stalker = should_refresh_stalker_playback( + input.input_type, + request_url_valid, + stream_info.as_ref().map(|(_, status, _, _)| *status), + ); + if should_refresh_stalker { + let force_stalker_refresh = + stream_info.as_ref().is_some_and(|(_, status, _, _)| status.is_client_error()); + let kind = stalker_stream_kind(stream_channel.cluster, item_type); + let resolve_result = re_resolve_stalker_url_singleflight( + app_state, + input, + stream_channel.provider_id, + kind, + force_stalker_refresh, + ) + .await; + match resolve_result { + Ok(Some(refreshed_url)) => { + if let Ok(url) = Url::parse(&refreshed_url) { + let default_user_agent = + app_state.app_config.config.load().default_user_agent.clone(); + let disabled_headers = app_state.get_disabled_headers(); + let mut options = ProviderStreamFactoryOptions::new( + &crate::api::model::ProviderStreamFactoryParams { + addr: fingerprint.addr, + item_type, + share_stream, + stream_options, + 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), + client_ip: Some(&fingerprint.client_ip), + stream_channel: Some(stream_channel), + connect_failure_stage: Some(FailureStage::ProviderOpen), + content_representation, + }, + ); + options.set_provider(input.get_resolve_provider(url.as_ref())); + options.require_public_destination(); + let retry_reconnect_flag = options.get_reconnect_flag_clone(); + let retried = create_provider_stream( + &app_state.provider_stream_ctx(), + &app_state.http_client.load(), + options, + ) + .await; + if let Some(response) = retried { + stream = Some(response.stream); + stream_info = response.info; + provider_session_headers = response.provider_session_headers; + reconnect_flag = Some(retry_reconnect_flag); + request_url = refreshed_url; + } else { + // Keep the original stream/stream_info: the upstream response + // might still be serveable, and its status is needed for reporting. + debug!("Stalker re-resolve retry could not open a stream, keeping original provider response"); + } + } + } + Ok(None) => {} + Err(err) => { + warn!( + "Failed to refresh Stalker playback URL: {}", + sanitize_sensitive_info(&err.to_string()) + ); + } + } + } + (stream, stream_info, provider_session_headers, reconnect_flag) + }; + + if log_enabled!(log::Level::Debug) { + if let Some((headers, status_code, response_url, _custom_video_type)) = stream_info.as_ref() { + debug!( + "Responding stream request {} with status {}, headers {:?}", + sanitize_sensitive_info(response_url.as_ref().map_or(stream_url, |s| s.as_str())), + status_code, + headers + ); + } + } + + // An intentional deferred open must retain its grace allocation until body polling + // resumes the provider request. Other failed opens release their allocation here. + let provider_handle = if stream.is_none() && !defer_provider_stream_until_grace_check { + 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)); + None + } else { + streaming_strategy.provider_handle.take() + }; + + Ok(StreamDetails { + stream, + stream_info, + provider_name: guard_provider_name.clone(), + request_url: Some(request_url.clone()), + session_headers: session_headers.cloned(), + provider_session_headers, + grace_period: grace_period_options, + provider_grace_active, + disable_provider_grace: false, + reconnect_flag, + provider_handle, + content_representation, + grace_resolution_context, + }) + } + } +} + +pub struct RedirectParams<'a, P> +where + P: PlaylistEntry, +{ + pub item: &'a P, + pub provider_id: Option, + pub cluster: XtreamCluster, + pub target_type: TargetType, + pub target: &'a ConfigTarget, + pub input: &'a ConfigInput, + pub user: &'a ProxyUserCredentials, + pub stream_ext: Option<&'a str>, + pub req_context: ApiStreamContext, + pub action_path: &'a str, +} + +impl

RedirectParams<'_, P> +where + P: PlaylistEntry, +{ + pub fn get_query_path(&self, provider_id: u32, url: &str) -> String { + let extension = self.stream_ext.map_or_else( + || extract_extension_from_url(url).map_or_else(String::new, ToString::to_string), + ToString::to_string, + ); + + // if there is an action_path (like for timeshift duration/start), it will be added in front of the stream_id + if self.action_path.is_empty() { + concat_string!(&provider_id.to_string(), &extension) + } else { + concat_string!(&trim_slash(self.action_path), "/", &provider_id.to_string(), &extension) + } + } +} + +pub async fn redirect_response<'a, P>( + app_state: &Arc, + params: &'a RedirectParams<'a, P>, +) -> Option +where + P: PlaylistEntry, +{ + let item_type = params.item.get_item_type(); + let provider_url = params.item.get_provider_url(); + if is_media_server_playback_url(params.input, provider_url.as_ref()) { + return None; + } + + let redirect_request = params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type); + let is_hls_request = item_type == PlaylistItemType::LiveHls || params.stream_ext == Some(HLS_EXT); + let is_dash_request = + (!is_hls_request && item_type == PlaylistItemType::LiveDash) || params.stream_ext == Some(DASH_EXT); + + if params.target_type == TargetType::M3u { + if redirect_request || is_dash_request { + let redirect_url: Arc = if is_hls_request { + replace_url_extension(&provider_url, HLS_EXT).into() + } else { + provider_url.clone() + }; + let redirect_url = + if is_dash_request { replace_url_extension(&redirect_url, DASH_EXT).into() } else { redirect_url }; + let redirect_url = get_redirect_alternative_url(app_state, &redirect_url, params.input).await; + let redirect_url = match resolve_redirect_location(Some(params.input), &redirect_url) { + Ok(url) => url, + Err(err) => { + error!("Failed to resolve redirect url: {}", sanitize_sensitive_info(&err.to_string())); + return Some(StatusCode::BAD_REQUEST.into_response()); + } + }; + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(redirect_url.as_ref())); + return Some(redirect(redirect_url.as_ref()).into_response()); + } + } else if params.target_type == TargetType::Xtream { + let Some(provider_id) = params.provider_id else { + return Some(StatusCode::BAD_REQUEST.into_response()); + }; + + if redirect_request { + let target_name = params.target.name.as_str(); + let virtual_id = params.item.get_virtual_id(); + let stream_url = match get_xtream_player_api_stream_url( + params.input, + params.req_context, + ¶ms.get_query_path(provider_id, &provider_url), + &provider_url, + ) { + None => { + error!( + "Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", + params.req_context + ); + return Some(StatusCode::BAD_REQUEST.into_response()); + } + Some(url) => match app_state.active_provider.get_next_provider(¶ms.input.name).await { + Some(provider_cfg) => match get_stream_alternative_url(&url, params.input, &provider_cfg) { + Some(stream_url) => stream_url, + None => return Some(StatusCode::BAD_REQUEST.into_response()), + }, + None => url.to_string(), + }, + }; + let stream_url = match resolve_redirect_location(Some(params.input), &stream_url) { + Ok(url) => url, + Err(err) => { + error!("Failed to resolve redirect url: {}", sanitize_sensitive_info(&err.to_string())); + return Some(StatusCode::BAD_REQUEST.into_response()); + } + }; + + // hls or dash redirect + if is_dash_request { + let redirect_url = if is_hls_request { + &replace_url_extension(&stream_url, HLS_EXT) + } else { + &replace_url_extension(&stream_url, DASH_EXT) + }; + debug_if_enabled!( + "Redirecting stream request to {}", + sanitize_sensitive_info(resolve_request_url_for_logging(params.input, redirect_url).as_ref()) + ); + return Some(redirect(redirect_url).into_response()); + } + + debug_if_enabled!( + "Redirecting stream request to {}", + sanitize_sensitive_info(resolve_request_url_for_logging(params.input, stream_url.as_ref()).as_ref()) + ); + return Some(redirect(stream_url.as_ref()).into_response()); + } + } + + None +} + +fn is_media_server_playback_url(input: &ConfigInput, stream_url: &str) -> bool { + input.input_type == InputType::Plex || is_media_server_stream_ref_url(stream_url) +} + +fn is_media_server_stream_ref_url(stream_url: &str) -> bool { + Url::parse(stream_url).is_ok_and(|url| url.scheme() == "media-server") +} + +fn is_throttled_stream(item_type: PlaylistItemType, throttle_kbps: usize) -> bool { + throttle_kbps > 0 + && matches!( + item_type, + PlaylistItemType::Video + | PlaylistItemType::Series + | PlaylistItemType::SeriesInfo + | PlaylistItemType::Catchup + | PlaylistItemType::LocalVideo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo + ) +} + +fn prepare_body_stream(app_state: &Arc, item_type: PlaylistItemType, stream: S) -> axum::body::Body +where + S: futures::Stream> + Send + 'static, +{ + let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); + let body_stream = if is_throttled_stream(item_type, throttle_kbps) { + info!("Stream throttling active: {}", human_readable_kbps(u64::try_from(throttle_kbps).unwrap_or_default())); + axum::body::Body::from_stream(ThrottledStream::new(stream.boxed(), throttle_kbps)) + } else { + axum::body::Body::from_stream(stream) + }; + body_stream +} + +async fn open_media_server_stream_for_input( + app_state: &Arc, + input: &ConfigInput, + stream_url: &str, + req_headers: &HeaderMap, +) -> Result<(BoxedProviderStream, ProviderStreamInfo), MediaServerError> { + let stream_ref = parse_media_server_stream_ref(&input.name, stream_url)?; + let range = req_headers.get(header::RANGE).and_then(|value| value.to_str().ok()); + let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone()); + + let response = match input.input_type { + InputType::Plex => { + let client = input.plex_catalog_client(http_client)?; + open_media_server_proxy_stream_response(&client, &stream_ref, range).await? + } + InputType::Emby | InputType::Jellyfin => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail("media-server playback proxy is not implemented for this input type")); + } + InputType::M3u + | InputType::Xtream + | InputType::M3uBatch + | InputType::XtreamBatch + | InputType::Stalker + | InputType::StalkerBatch + | InputType::Library + | InputType::Staged => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail("playlist item is not backed by a media-server input")); + } + }; + + let headers = response + .headers + .iter() + .filter(|(key, _)| !is_hop_by_hop_response_header(key)) + .filter_map(|(key, value)| value.to_str().ok().map(|value| (key.to_string(), value.to_string()))) + .collect::>(); + let status = response.status; + let stream = response.body.map_err(|err| StreamError::Stream(err.to_string())).boxed(); + Ok((stream, Some((headers, status, None, None)))) +} + +fn is_hop_by_hop_response_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "trailers" + | "transfer-encoding" + | "upgrade" + ) +} + +fn no_custom_video_fallback_status(app_config: &AppConfig) -> StatusCode { + // Two reasons we have no custom-video response: + // 1. Operator disabled `custom_stream_response_enabled` → return the + // configured fallback status (e.g. 502) so reverse proxies handle the + // socket consistently. + // 2. Operator enabled custom-video but the concrete resource is missing + // → return `400` so downstream `proxy_intercept_errors on;` (Nginx) + // can sever the socket instead of looping on `200 OK`. + // Collapsing both into the configured status code broke the Nginx-intercept + // contract that the operator relied on by enabling custom-video in the + // first place. + if is_custom_video_stream_enabled(app_config) { + StatusCode::BAD_REQUEST + } else { + get_custom_stream_response_error_status(app_config) + } +} + +/// # Panics +#[allow(clippy::too_many_lines)] +pub async fn force_provider_stream_response( + fingerprint: &Fingerprint, + app_state: &Arc, + user_session: &UserSession, + mut stream_channel: StreamChannel, + ctx: ForceStreamRequestContext<'_>, + grace_mode: Option, +) -> impl IntoResponse + Send { + let _transition_guard = + app_state.active_users.acquire_playback_transition(&ctx.user.username, &user_session.token).await; + let stream_options = get_stream_options(&app_state.app_config); + let share_stream = false; + let connection_permission = UserConnectionPermission::Allowed; + let item_type = stream_channel.item_type; + + // Forced reopens must clear stale provider slots before reacquiring. For adaptive HLS/DASH + // and Catchup sessions we only target old active stream sockets of the same session, never + // manifest-only session addresses, otherwise the controlling playlist request gets torn down. + let cleanup_addrs = if item_type.is_live_adaptive() || item_type == PlaylistItemType::Catchup { + app_state + .active_users + .adaptive_session_stream_cleanup_addrs(&ctx.user.username, &user_session.token, &fingerprint.addr) + .await + } else { + session_reacquire_cleanup_addrs(user_session, &fingerprint.addr) + }; + + if cleanup_addrs.is_empty() { + debug_if_enabled!( + "Forced reopen cleanup had no stale targets for item_type={item_type:?} session={} current_addr={}", + sanitize_sensitive_info(&user_session.token), + sanitize_sensitive_info(&fingerprint.addr.to_string()) + ); + } else { + debug_if_enabled!( + "Forced reopen cleanup releasing {} stale target(s) for item_type={item_type:?} session={} current_addr={}", + cleanup_addrs.len(), + sanitize_sensitive_info(&user_session.token), + sanitize_sensitive_info(&fingerprint.addr.to_string()) + ); + cleanup_forced_reopen_addrs(app_state, item_type, &cleanup_addrs).await; + } + + // Provider-affine playback must stay on the same provider account across seeks/range reconnects. + // Only non-affine sessions may fall back to a different account in the same lineup. + let preferred_provider = Some(&user_session.provider); + let allow_forced_provider_fallback = !item_type.requires_provider_affinity(); + // Never allow provider-side grace for forced seek/session reacquire. + // Over-allocation here would break provider-side one-connection limits. + let allow_provider_grace = false; + let connection_kind = user_session.connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); + + let stream_details = match create_stream_response_details( + app_state, + &stream_options, + &user_session.stream_url, + &ctx.user.username, + fingerprint, + ctx.req_headers, + ctx.input, + &stream_channel, + item_type, + ctx.content_representation, + share_stream, + connection_permission, + preferred_provider, + allow_forced_provider_fallback, + allow_provider_grace, + VirtualId::new(stream_channel.virtual_id), + connection_priority_for_kind(ctx.user, connection_kind), + 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, + ) + .await + { + Ok(stream_details) => stream_details, + Err(err) => { + app_state + .active_users + .release_unbound_session_reservation(&ctx.user.username, &user_session.token, None, false) + .await; + error!("Failed to stream: {err}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let deferred_grace_hold_stream = stream_details.has_deferred_provider_open(); + + if stream_details.has_stream() || deferred_grace_hold_stream { + let metering = prepare_stream_metering( + app_state, + user_session.stream_url.as_ref(), + share_stream, + stream_details.stream.is_some(), + stream_details.has_deferred_provider_open(), + ) + .await; + let provider_response = + stream_details.stream_info.as_ref().map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt)); + if ctx.session_reservation_ttl_secs > 0 { + if let Some(provider_name) = stream_details.provider_name.as_ref() { + app_state + .active_provider + .refresh_provider_reservation(provider_name, &user_session.token, ctx.session_reservation_ttl_secs) + .await; + } + } + app_state.active_users.update_session_addr(&ctx.user.username, &user_session.token, &fingerprint.addr).await; + stream_channel.shared = share_stream; + let socket_bound = user_session.socket_bound; + let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { + stream_details, + app_state, + user: ctx.user, + connection_permission, + connection_kind: user_session.connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal), + fingerprint, + stream_channel, + socket_bound, + session_token: Some(&user_session.token), + req_headers: ctx.req_headers, + meter_uid: metering.meter_uid, + meter_stream: metering.meter_stream, + }) + .await; + + let (status_code, header_map) = get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); + let mut response = axum::response::Response::builder().status(status_code); + for (key, value) in &header_map { + response = response.header(key, value); + } + + let body_stream = prepare_body_stream(app_state, item_type, stream); + debug_if_enabled!( + "Streaming provider forced stream request from {}", + sanitize_sensitive_info( + resolve_request_url_for_logging(ctx.input, user_session.stream_url.as_ref()).as_ref() + ) + ); + let mut response = try_unwrap_body!(response.body(body_stream)); + mark_response_as_uncompressed(&mut response); + return response; + } + + app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; + app_state + .active_users + .release_unbound_session_reservation(&ctx.user.username, &user_session.token, None, false) + .await; + if let (Some(stream), _stream_info) = + create_channel_unavailable_stream(&app_state.app_config, &[], StatusCode::SERVICE_UNAVAILABLE) + { + app_state + .connection_manager + .update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable) + .await; + debug!("Streaming custom stream"); + let mut response = try_unwrap_body!(axum::response::Response::builder() + .status(StatusCode::OK) + .body(axum::body::Body::from_stream(stream))); + mark_response_as_uncompressed(&mut response); + response + } else { + no_custom_video_fallback_status(&app_state.app_config).into_response() + } +} + +/// # Panics +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(crate) async fn stream_response( + fingerprint: &Fingerprint, + app_state: &Arc, + session_token: &str, + request_class: Option, + mut stream_channel: StreamChannel, + stream_url: &str, + pinned_provider: Option<&Arc>, + req_headers: &HeaderMap, + input: &Arc, + target: &Arc, + user: &ProxyUserCredentials, + connection_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + allow_exhausted_shared_reconnect: bool, + grace_mode: Option, +) -> impl IntoResponse + Send { + let _transition_guard = app_state.active_users.acquire_playback_transition(&user.username, session_token).await; + let request_log_stream_url = resolve_request_url_for_logging(input, stream_url); + if log_enabled!(log::Level::Trace) { + trace!("Try to open stream {}", sanitize_sensitive_info(request_log_stream_url.as_ref())); + } + + let virtual_id = stream_channel.virtual_id; + let item_type = stream_channel.item_type; + let playback_extension = extract_extension_from_url(stream_url); + let socket_bound = is_socket_bound_playback_session(item_type, playback_extension); + let mut connection_permission = connection_permission; + let mut connection_kind = connection_kind; + let activation = activate_session_before_stream_open( + app_state, + SessionActivationRequest { + fingerprint, + input, + user, + session_token, + request_class, + virtual_id: VirtualId::new(virtual_id), + item_type, + stream_url, + connection_permission, + connection_kind, + socket_bound, + }, + ) + .await; + let grace_mode = activation.grace_mode.or(grace_mode); + connection_permission = activation.admission.permission; + connection_kind = activation.admission.kind.unwrap_or(connection_kind); + + let allow_shared_reuse = + connection_permission != UserConnectionPermission::Exhausted || allow_exhausted_shared_reconnect; + + let share_stream = is_stream_share_enabled(item_type, target); + let _shared_lock = if share_stream { + let write_lock = app_state.app_config.file_locks.write_lock_str(stream_url).await; + + if allow_shared_reuse { + if let Some(value) = try_shared_stream_response_if_any( + app_state, + stream_url, + fingerprint, + user, + connection_permission, + connection_kind, + stream_channel.clone(), + session_token, + req_headers, + ) + .await + { + return value.into_response(); + } + } + Some(write_lock) + } else { + // Opportunistic cross-target sharing: if another target already runs a shared stream + // for the same provider URL, subscribe to it instead of opening a separate connection. + if item_type == PlaylistItemType::Live && allow_shared_reuse { + if let Some(value) = try_shared_stream_response_if_any( + app_state, + stream_url, + fingerprint, + user, + connection_permission, + connection_kind, + stream_channel.clone(), + session_token, + req_headers, + ) + .await + { + debug_if_enabled!("Opportunistic shared stream reuse for {}", sanitize_sensitive_info(stream_url)); + return value.into_response(); + } + } + None + }; + + if connection_permission == UserConnectionPermission::Exhausted { + app_state + .active_users + .release_unbound_session_reservation( + &user.username, + session_token, + activation.placeholder_transition_version, + activation.placeholder_transition_version.is_some(), + ) + .await; + record_connect_failed_attempt(ConnectFailedAttempt { + app_state, + fingerprint, + user, + stream_channel: stream_channel.clone(), + provider_name: input.name.clone(), + req_headers, + reason: ConnectFailureReason::UserConnectionsExhausted, + failure_stage: FailureStage::Admission, + }); + return create_custom_video_stream_response( + &app_state.provider_stream_ctx(), + &fingerprint.addr, + CustomVideoStreamType::UserConnectionsExhausted, + ) + .into_response(); + } + + let stream_options = get_stream_options(&app_state.app_config); + 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, + stream_url, + &user.username, + fingerprint, + req_headers, + input, + &stream_channel, + item_type, + if item_type == PlaylistItemType::Catchup { + crate::api::model::ProviderContentRepresentationMode::Identity + } else { + crate::api::model::ProviderContentRepresentationMode::PreserveOrigin + }, + share_stream, + connection_permission, + pinned_provider, + pinned_provider.is_none(), + true, + VirtualId::new(stream_channel.virtual_id), + connection_priority_for_kind(user, connection_kind), + 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(), + ) + .await + { + Ok(stream_details) => stream_details, + Err(err) => { + app_state + .active_users + .release_unbound_session_reservation( + &user.username, + session_token, + activation.placeholder_transition_version, + activation.placeholder_transition_version.is_some(), + ) + .await; + error!("Failed to stream: {err}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + if item_type == PlaylistItemType::Catchup { + if let Some(provider_stream) = stream_details.stream.take() { + let probe_deadline = Duration::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms().max(1)); + match probe_catchup_payload(provider_stream, probe_deadline).await { + Ok(CatchupPayload::Direct(provider_stream)) => stream_details.stream = Some(provider_stream), + Ok(CatchupPayload::HlsManifest(manifest)) => { + return detected_catchup_hls_response(DetectedCatchupHlsResponseParams { + app_state, + stream_details, + manifest, + user, + target, + input, + fingerprint, + session_token, + virtual_id: VirtualId::new(virtual_id), + connection_permission, + connection_kind, + fallback_stream_url: stream_url, + }) + .await; + } + Err(err) => { + error!("Failed to inspect catch-up payload: {err}"); + cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token) + .await; + return StatusCode::BAD_GATEWAY.into_response(); + } + } + } + } + + // When no provider stream is available, still create an ActiveClientStream if a grace period + // needs to resolve (provider-grace with hold_stream, or user-grace). The grace task will + // determine the correct mode (UserExhausted / ProviderExhausted / Inner) and serve the + // appropriate custom video or terminate cleanly. + let deferred_grace_hold_stream = + stream_details.has_deferred_provider_open() || connection_permission == UserConnectionPermission::GracePeriod; + + if stream_details.has_stream() || deferred_grace_hold_stream { + // let content_length = get_stream_content_length(provider_response.as_ref()); + let provider_response = stream_details + .stream_info + .as_ref() + .map(|(h, sc, response_url, cvt)| (h.clone(), *sc, response_url.clone(), *cvt)); + let provider_name = stream_details.provider_name.clone(); + let actual_request_url = stream_details.request_url.clone().unwrap_or_else(|| Arc::::from(stream_url)); + let log_actual_request_url = resolve_request_url_for_logging(input, actual_request_url.as_ref()); + + debug_if_enabled!( + "Provider request mapping: allocated_provider={} actual_request_url={}", + sanitize_sensitive_info(provider_name.as_deref().unwrap_or("?")), + sanitize_sensitive_info(log_actual_request_url.as_ref()) + ); + + if let Some((headers, status, _response_url, Some(CustomVideoStreamType::Provisioning))) = + stream_details.stream_info.as_ref() + { + debug_if_enabled!("panel_api provisioning response to client: status={} headers={:?}", status, headers); + } + + let metering = prepare_stream_metering( + app_state, + stream_url, + share_stream, + stream_details.stream.is_some(), + stream_details.has_deferred_provider_open(), + ) + .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() { + is_stream_shared = false; + } + } + let provider_handle = if is_stream_shared && !stream_details.has_deferred_provider_open() { + stream_details.provider_handle.take() + } else { + None + }; + + stream_channel.shared = is_stream_shared; + if is_stream_shared { + stream_channel.shared_joined_existing = Some(false); + stream_channel.shared_stream_id = Some(u64::from(metering.meter_uid)); + } else { + stream_channel.shared_joined_existing = None; + stream_channel.shared_stream_id = None; + } + let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { + stream_details, + app_state, + user, + connection_permission, + connection_kind, + fingerprint, + stream_channel, + socket_bound, + session_token: Some(session_token), + req_headers, + meter_uid: metering.meter_uid, + meter_stream: metering.meter_stream, + }) + .await; + let stream_resp = if is_stream_shared { + debug_if_enabled!( + "Streaming shared stream request from {}", + sanitize_sensitive_info(log_actual_request_url.as_ref()) + ); + // Shared Stream response + let shared_headers = provider_response.as_ref().map_or_else(Vec::new, |(h, _, _, _)| h.clone()); + if let Some((broadcast_stream, _shared_provider)) = SharedStreamManager::register_shared_stream( + SharedStreamCtx { + app_config: &app_state.app_config, + shared_stream_manager: &app_state.shared_stream_manager, + active_provider: &app_state.active_provider, + connection_manager: &app_state.connection_manager, + }, + stream_url, + stream, + &fingerprint.addr, + shared_headers, + stream_options.buffer_size, + provider_handle, + connection_priority_for_kind(user, connection_kind), + connection_kind, + ) + .await + { + let (status_code, header_map) = + get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); + let mut response = axum::response::Response::builder().status(status_code); + for (key, value) in &header_map { + response = response.header(key, value); + } + let mut response = try_unwrap_body!(response.body(axum::body::Body::from_stream(broadcast_stream))); + mark_response_as_uncompressed(&mut response); + response + } else { + StatusCode::BAD_REQUEST.into_response() + } + } else { + // Previously, we always persisted the provider's final request URL into the session. + // For VOD-like playback that can be the wrong thing to reuse later: a seek or reopen + // should start from the canonical playback entrypoint, not from a provider-specific + // redirected target that happened to be used for an earlier request. + // For Movies/Series/Catchup we therefore keep the canonical request URL in the session. + // That avoids "session poisoning" where later seeks/resumes inherit a non-canonical URL. + // For live playback we still keep the redirected URL when available, because staying on + // the chosen upstream edge/server is often desirable there. + let session_url: Cow<'_, str> = if matches!( + item_type, + PlaylistItemType::Catchup + | PlaylistItemType::Video + | PlaylistItemType::LocalVideo + | PlaylistItemType::Series + | PlaylistItemType::LocalSeries + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeriesInfo + ) { + Cow::Owned(actual_request_url.to_string()) + } else { + provider_response + .as_ref() + .and_then(|(_, _, u, _)| u.as_ref()) + .map_or_else(|| Cow::Owned(actual_request_url.to_string()), |url| Cow::Owned(url.to_string())) + }; + let log_session_url = resolve_request_url_for_logging(input, session_url.as_ref()); + if log_enabled!(log::Level::Debug) { + if log_session_url.eq(log_actual_request_url.as_ref()) { + debug!( + "Streaming stream request from {}", + sanitize_sensitive_info(log_actual_request_url.as_ref()) + ); + } else { + debug!( + "Streaming stream request for {} from {}", + sanitize_sensitive_info(log_actual_request_url.as_ref()), + sanitize_sensitive_info(log_session_url.as_ref()) + ); + } + } + let (status_code, header_map) = + get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); + let mut response = axum::response::Response::builder().status(status_code); + for (key, value) in &header_map { + response = response.header(key, value); + } + + if let Some(provider) = provider_name { + if matches!( + item_type, + PlaylistItemType::LiveHls + | PlaylistItemType::LiveDash + | PlaylistItemType::Video + | PlaylistItemType::Series + | PlaylistItemType::SeriesInfo + | PlaylistItemType::LocalSeries + | PlaylistItemType::LocalSeriesInfo + | PlaylistItemType::Catchup + ) { + let _ = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id, + provider: &provider, + stream_url: &session_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(connection_kind), + socket_bound, + }) + .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; + } + } + } + } + + let body_stream = prepare_body_stream(app_state, item_type, stream); + let mut response = try_unwrap_body!(response.body(body_stream)); + mark_response_as_uncompressed(&mut response); + response + }; + + return stream_resp.into_response(); + } + app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; + app_state + .active_users + .release_unbound_session_reservation( + &user.username, + session_token, + activation.placeholder_transition_version, + activation.placeholder_transition_version.is_some(), + ) + .await; + no_custom_video_fallback_status(&app_state.app_config).into_response() +} + +enum CatchupPayload { + Direct(BoxedProviderStream), + HlsManifest(Bytes), +} + +struct DetectedCatchupHlsResponseParams<'a> { + app_state: &'a Arc, + stream_details: StreamDetails, + manifest: Bytes, + user: &'a ProxyUserCredentials, + target: &'a ConfigTarget, + input: &'a ConfigInput, + fingerprint: &'a Fingerprint, + session_token: &'a str, + virtual_id: VirtualId, + connection_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + fallback_stream_url: &'a str, +} + +async fn detected_catchup_hls_response(params: DetectedCatchupHlsResponseParams<'_>) -> axum::response::Response { + let DetectedCatchupHlsResponseParams { + app_state, + mut stream_details, + manifest, + user, + target, + input, + fingerprint, + session_token, + virtual_id, + connection_permission, + connection_kind, + fallback_stream_url, + } = params; + + let Some(provider) = stream_details.provider_name.clone() else { + cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; + return StatusCode::BAD_GATEWAY.into_response(); + }; + let Some(server_info) = app_state.app_config.get_user_server_info(user) else { + cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; + return StatusCode::BAD_GATEWAY.into_response(); + }; + let Ok(content) = std::str::from_utf8(&manifest) else { + cleanup_failed_detected_catchup_hls(app_state, &mut stream_details, &user.username, session_token).await; + return StatusCode::BAD_GATEWAY.into_response(); + }; + + let response_url = stream_details + .stream_info + .as_ref() + .and_then(|(_, _, response_url, _)| response_url.as_ref()) + .map_or_else(|| fallback_stream_url.to_string(), ToString::to_string); + let base_url = server_info.get_base_url(); + let encrypt_secret = app_state.get_encrypt_secret(); + let rewritten = rewrite_hls( + user, + &RewriteHlsProps { + secret: &encrypt_secret, + base_url: &base_url, + content, + hls_url: response_url, + target_id: target.id, + virtual_id: virtual_id.get(), + input_id: input.id, + user_token: Some(session_token), + }, + ); + + let request_url = stream_details.request_url.as_deref().unwrap_or(fallback_stream_url); + let created_session_token = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id: virtual_id.get(), + provider: &provider, + stream_url: request_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(connection_kind), + socket_bound: false, + }) + .await; + if !stream_details.provider_session_headers.is_empty() { + app_state + .active_users + .update_session_provider_headers( + &user.username, + &created_session_token, + &stream_details.provider_session_headers, + ) + .await; + } + app_state + .active_provider + .refresh_provider_reservation(&provider, &created_session_token, get_catchup_session_ttl_secs(app_state)) + .await; + app_state.connection_manager.release_provider_handle(stream_details.provider_handle.take()).await; + app_state + .active_users + .release_unbound_session_reservation(&user.username, &created_session_token, None, false) + .await; + app_state.active_users.clear_unbound_session_addr(&user.username, &created_session_token, &fingerprint.addr).await; + + catchup_hls_manifest_response(rewritten) +} + +fn catchup_hls_manifest_response(content: String) -> axum::response::Response { + let mut response = try_unwrap_body!(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, crate::api::static_headers::CT_M3U.clone()) + .header(header::CACHE_CONTROL, crate::api::static_headers::CC_NO_STORE.clone()) + .body(Body::from(content))); + mark_response_as_uncompressed(&mut response); + response +} + +async fn cleanup_failed_detected_catchup_hls( + app_state: &Arc, + stream_details: &mut StreamDetails, + username: &str, + session_token: &str, +) { + app_state.connection_manager.release_provider_handle(stream_details.provider_handle.take()).await; + app_state.active_users.terminate_session(username, session_token).await; + app_state.active_provider.clear_provider_reservation(session_token).await; +} + +async fn probe_catchup_payload(stream: BoxedProviderStream, deadline: Duration) -> Result { + tokio::time::timeout(deadline, probe_catchup_payload_inner(stream)) + .await + .map_err(|_| StreamError::Stream("catch-up payload probe timed out".to_string()))? +} + +async fn probe_catchup_payload_inner(mut stream: BoxedProviderStream) -> Result { + const HLS_SIGNATURE: &[u8] = b"#EXTM3U"; + + let mut prefix = BytesMut::new(); + while prefix.len() < HLS_SIGNATURE.len() { + let Some(chunk) = stream.next().await else { + return Ok(CatchupPayload::Direct(stream::once(async move { Ok(prefix.freeze()) }).chain(stream).boxed())); + }; + prefix.extend_from_slice(&chunk?); + } + + if !prefix.starts_with(HLS_SIGNATURE) { + return Ok(CatchupPayload::Direct(stream::once(async move { Ok(prefix.freeze()) }).chain(stream).boxed())); + } + if prefix.len() > MAX_HLS_MANIFEST_BYTES { + return Err(StreamError::Stream("catch-up HLS manifest exceeds size limit".to_string())); + } + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if prefix.len().saturating_add(chunk.len()) > MAX_HLS_MANIFEST_BYTES { + return Err(StreamError::Stream("catch-up HLS manifest exceeds size limit".to_string())); + } + prefix.extend_from_slice(&chunk); + } + + Ok(CatchupPayload::HlsManifest(prefix.freeze())) +} + +fn get_stream_throttle(app_state: &Arc) -> u64 { + app_state + .app_config + .config + .load() + .reverse_proxy + .as_ref() + .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) + .map(|stream| stream.throttle_kbps) + .unwrap_or_default() +} + +fn is_stream_metrics_enabled(app_state: &Arc) -> bool { + app_state + .app_config + .config + .load() + .reverse_proxy + .as_ref() + .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) + .is_some_and(|stream| stream.metrics_enabled) +} + +async fn prepare_stream_metering( + app_state: &Arc, + stream_url: &str, + share_stream: bool, + has_stream: bool, + has_deferred_provider_open: bool, +) -> StreamMeteringConfig { + if !is_stream_metrics_enabled(app_state) { + return StreamMeteringConfig::default(); + } + + 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, meter_stream: has_stream || has_deferred_provider_open }; + } else if has_stream || has_deferred_provider_open { + let meter_uid = app_state.connection_manager.next_stream_uid(); + return StreamMeteringConfig { meter_uid, meter_stream: true }; + } + + StreamMeteringConfig::default() +} + +fn resolve_stream_config_u64( + stream_config: Option<&crate::model::StreamConfig>, + selector: impl FnOnce(&crate::model::StreamConfig) -> u64, + default_value: u64, +) -> u64 { + stream_config.map_or(default_value, selector) +} + +fn get_stream_config_u64( + app_state: &Arc, + selector: impl FnOnce(&crate::model::StreamConfig) -> u64, + default_value: u64, +) -> u64 { + let config = app_state.app_config.config.load(); + let stream_config = config.reverse_proxy.as_ref().and_then(|reverse_proxy| reverse_proxy.stream.as_ref()); + resolve_stream_config_u64(stream_config, selector, default_value) +} + +pub(crate) fn get_hls_session_ttl_secs(app_state: &Arc) -> u64 { + get_stream_config_u64(app_state, |stream| stream.hls_session_ttl_secs, default_hls_session_ttl_secs()) +} + +async fn cleanup_forced_reopen_addrs( + app_state: &Arc, + item_type: PlaylistItemType, + cleanup_addrs: &[SocketAddr], +) { + let close_client_socket = !(item_type.is_live_adaptive() || item_type == PlaylistItemType::Catchup); + for addr in cleanup_addrs { + app_state.connection_manager.release_provider_connection(addr).await; + if close_client_socket { + let _ = app_state.connection_manager.close_connection_signal(addr); + } + } +} + +pub(crate) fn get_catchup_session_ttl_secs(app_state: &Arc) -> u64 { + get_stream_config_u64(app_state, |stream| stream.catchup_session_ttl_secs, default_catchup_session_ttl_secs()) +} + +pub(crate) fn get_session_reservation_ttl_secs(app_state: &Arc, item_type: PlaylistItemType) -> u64 { + match item_type { + PlaylistItemType::LiveHls | PlaylistItemType::LiveDash => get_hls_session_ttl_secs(app_state), + PlaylistItemType::Catchup => get_catchup_session_ttl_secs(app_state), + _ => 0, + } +} + +/// 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, + _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, + stream_url: &str, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + connect_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + mut stream_channel: StreamChannel, + session_token: &str, + req_headers: &HeaderMap, +) -> Option { + if let Some((stream, provider)) = SharedStreamManager::subscribe_shared_stream( + SharedStreamCtx { + app_config: &app_state.app_config, + shared_stream_manager: &app_state.shared_stream_manager, + active_provider: &app_state.active_provider, + connection_manager: &app_state.connection_manager, + }, + stream_url, + &fingerprint.addr, + connection_priority_for_kind(user, connection_kind), + connection_kind, + ) + .await + { + debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url)); + if let Some(headers) = app_state.shared_stream_manager.get_shared_state_headers(stream_url).await { + let (status_code, header_map) = get_stream_response_with_headers(Some((headers.clone(), StatusCode::OK))); + let mut grace_period_options = app_state.get_grace_options(); + if connect_permission != UserConnectionPermission::GracePeriod { + grace_period_options.period_millis = 0; + } + let mut stream_details = StreamDetails::from_stream(stream, grace_period_options); + + stream_details.provider_name = provider; + let socket_bound = + is_socket_bound_playback_session(stream_channel.item_type, extract_extension_from_url(stream_url)); + if let Some(provider_name) = stream_details.provider_name.as_deref() { + let _ = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id: stream_channel.virtual_id, + provider: provider_name, + stream_url, + addr: &fingerprint.addr, + connection_permission: connect_permission, + connection_kind: Some(connection_kind), + socket_bound, + }) + .await; + } + stream_channel.shared = true; + stream_channel.shared_joined_existing = Some(true); + 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, meter_stream: false }; + let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { + stream_details, + app_state, + user, + connection_permission: connect_permission, + connection_kind, + fingerprint, + stream_channel, + socket_bound, + session_token: Some(session_token), + req_headers, + meter_uid: metering.meter_uid, + meter_stream: metering.meter_stream, + }) + .await + .boxed(); + let mut response = axum::response::Response::builder().status(status_code); + for (key, value) in &header_map { + response = response.header(key, value); + } + let mut response = response.body(axum::body::Body::from_stream(stream)).ok()?; + mark_response_as_uncompressed(&mut response); + return Some(response); + } + } + None +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(crate) async fn local_stream_response( + fingerprint: &Fingerprint, + app_state: &Arc, + pli: StreamChannel, + req_headers: &HeaderMap, + input: &ConfigInput, + _target: &ConfigTarget, + user: &ProxyUserCredentials, + connection_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + playback_session_token: Option<&str>, + request_class: Option, + check_path: bool, +) -> impl IntoResponse + Send { + let _transition_guard = if let Some(session_token) = playback_session_token { + Some(app_state.active_users.acquire_playback_transition(&user.username, session_token).await) + } else { + None + }; + if log_enabled!(log::Level::Trace) { + trace!("Try to open stream {}", sanitize_sensitive_info(&pli.url)); + } + + let mut connection_permission = connection_permission; + let mut grace_mode = None; + if connection_permission == UserConnectionPermission::Exhausted { + let allow_session_reopen = if let Some(session_token) = playback_session_token { + user.max_connections > 0 + && app_state + .active_users + .connection_permission_for_session( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await + != UserConnectionPermission::Exhausted + } else { + false + }; + if !allow_session_reopen { + record_connect_failed_attempt(ConnectFailedAttempt { + app_state, + fingerprint, + user, + stream_channel: pli.clone(), + provider_name: input.name.clone(), + req_headers, + reason: ConnectFailureReason::UserConnectionsExhausted, + failure_stage: FailureStage::Admission, + }); + return create_custom_video_stream_response( + &app_state.provider_stream_ctx(), + &fingerprint.addr, + CustomVideoStreamType::UserConnectionsExhausted, + ) + .into_response(); + } + connection_permission = UserConnectionPermission::Allowed; + } + + 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 canonical = match tokio::fs::canonicalize(&path).await { + Ok(canonical) => canonical, + Err(err) => { + error!("Local file path is corrupt {}: {err}", path.display()); + return StatusCode::NOT_FOUND.into_response(); + } + }; + + if check_path { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let Ok(canonical_metadata) = tokio::fs::metadata(&canonical).await else { return internal_server_error!() }; + 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(); + } + } + #[cfg(windows)] + match same_windows_file_identity(&file, &canonical).await { + Ok(true) => {} + Ok(false) => { + error!("TOCTOU race detected: file swapped during local_stream_response"); + return StatusCode::FORBIDDEN.into_response(); + } + Err(err) => { + error!("Could not verify local file identity {}: {err}", canonical.display()); + return internal_server_error!(); + } + } + #[cfg(not(any(unix, windows)))] + { + error!("Secure local file identity validation is unsupported on this platform"); + return StatusCode::FORBIDDEN.into_response(); + } + + let Some(library_paths) = app_state + .app_config + .config + .load() + .library + .as_ref() + .map(|lib| lib.scan_directories.iter().map(|dir| dir.path.clone()).collect::>()) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + + // Verify path is within allowed media directories + // (requires configuration of allowed base paths) + if !is_path_within_allowed_directories(&canonical, &library_paths) { + return StatusCode::FORBIDDEN.into_response(); + } + } + + let file_size = opened_metadata.len(); + + let range = req_headers.get("range").and_then(|v| v.to_str().ok()).and_then(parse_range); + + let (start, end) = if let Some((req_start, req_end)) = range { + if file_size == 0 || req_start >= file_size { + return StatusCode::RANGE_NOT_SATISFIABLE.into_response(); + } + let end = req_end.unwrap_or(file_size - 1).min(file_size - 1); + if end < req_start { + return StatusCode::RANGE_NOT_SATISFIABLE.into_response(); + } + (req_start, end) + } else { + if file_size == 0 { + // Serve empty file + let body = axum::body::Body::empty(); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::OK; + let headers = response.headers_mut(); + if let Some(ext) = get_file_extension(&pli.url) { + let ct = content_type_from_ext(&ext); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + } else { + headers.insert(header::CONTENT_TYPE, CT_OCTET.clone()); //HeaderValue::from_static("application/octet-stream")); + } + headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("0")); + return response.into_response(); + } + (0, file_size - 1) + }; + + let content_length = end - start + 1; + + if start > 0 { + if let Err(_err) = file.seek(SeekFrom::Start(start)).await { + return internal_server_error!(); + } + } + + let stream = + ReaderStream::new(file.take(content_length)).map_err(|err| StreamError::Stream(err.to_string())).boxed(); + let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); + let stream = if is_throttled_stream(pli.item_type, throttle_kbps) { + info!("Stream throttling active: {}", human_readable_kbps(u64::try_from(throttle_kbps).unwrap_or_default())); + ThrottledStream::new(stream, throttle_kbps).boxed() + } else { + stream + }; + let socket_bound = is_socket_bound_playback_session(pli.item_type, extract_extension_from_url(&pli.url)); + let mut connection_kind = connection_kind; + if let Some(session_token) = playback_session_token { + let activation = activate_session_before_stream_open( + app_state, + SessionActivationRequest { + fingerprint, + input, + user, + session_token, + request_class, + virtual_id: VirtualId::new(pli.virtual_id), + item_type: pli.item_type, + stream_url: &pli.url, + connection_permission, + connection_kind, + socket_bound, + }, + ) + .await; + grace_mode = activation.grace_mode; + connection_permission = activation.admission.permission; + connection_kind = activation.admission.kind.unwrap_or(connection_kind); + + if connection_permission == UserConnectionPermission::Exhausted { + app_state + .active_users + .release_unbound_session_reservation( + &user.username, + session_token, + activation.placeholder_transition_version, + activation.placeholder_transition_version.is_some(), + ) + .await; + return create_custom_video_stream_response( + &app_state.provider_stream_ctx(), + &fingerprint.addr, + CustomVideoStreamType::UserConnectionsExhausted, + ) + .into_response(); + } + } + let mut grace_period_options = app_state.get_grace_options(); + if connection_permission != UserConnectionPermission::GracePeriod { + grace_period_options.period_millis = 0; + } + if let Some(resolved_mode) = grace_mode { + grace_period_options.hold_stream = matches!(resolved_mode, crate::api::model::GraceMode::Hold); + } + let resolved_connection_kind = if let Some(session_token) = playback_session_token { + app_state + .active_users + .get_and_update_user_session(&user.username, session_token) + .await + .and_then(|session| session.connection_kind) + .unwrap_or(connection_kind) + } else { + connection_kind + }; + if let Some(session_token) = playback_session_token { + let _ = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id: pli.virtual_id, + provider: input.name.as_ref(), + stream_url: &pli.url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(resolved_connection_kind), + socket_bound, + }) + .await; + } + let metering = prepare_stream_metering(app_state, &pli.url, false, true, false).await; + let stream = create_active_client_stream(crate::api::model::ActiveClientStreamParams { + stream_details: StreamDetails::from_stream(stream, grace_period_options), + app_state, + user, + connection_permission, + connection_kind: resolved_connection_kind, + fingerprint, + stream_channel: pli.clone(), + socket_bound, + session_token: playback_session_token, + req_headers, + meter_uid: metering.meter_uid, + meter_stream: metering.meter_stream, + }) + .await; + + let mut response = Response::new(axum::body::Body::from_stream(stream)); + + *response.status_mut() = if range.is_some() { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; + + let headers = response.headers_mut(); + if let Some(ext) = get_file_extension(&pli.url) { + let ct = content_type_from_ext(&ext); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + } else { + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")); + } + headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); + if let Ok(header_value) = HeaderValue::from_str(&content_length.to_string()) { + headers.insert(header::CONTENT_LENGTH, header_value); + } + + if range.is_some() { + if let Ok(header_value) = HeaderValue::from_str(&format!("bytes {start}-{end}/{file_size}")) { + headers.insert(header::CONTENT_RANGE, header_value); + } + } + + mark_response_as_uncompressed(&mut response); + response +} + +fn is_path_within_allowed_directories(sub_path: &Path, root_paths: &[String]) -> bool { + for root_path in root_paths { + if sub_path.starts_with(PathBuf::from(root_path)) { + return true; + } + } + false +} + +pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { + (item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */) + && target.options.as_ref().is_some_and(ConfigTargetOptions::share_live_mpeg_ts_enabled) +} + +pub fn is_hls_stream_share_enabled(target: &ConfigTarget) -> bool { + target.options.as_ref().is_some_and(ConfigTargetOptions::share_live_hls_enabled) +} + +fn get_add_cache_content( + res_url: &str, + mime_type: Option, + cache: &Arc>>, +) -> Arc { + let resource_url = String::from(res_url); + let cache = Arc::clone(cache); + let add_cache_content: Arc = Arc::new(move |size| { + let res_url = resource_url.clone(); + let mime_type = mime_type.clone(); + // todo spawn, replace with unboundchannel + let cache = Arc::clone(&cache); + tokio::spawn(async move { + if let Some(cache) = cache.load().as_ref() { + let _ = cache.write().await.add_content(&res_url, mime_type, size); + } + }); + }); + add_cache_content +} + +fn get_mime_type(headers: &HeaderMap, resource_url: &str) -> Option { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) // Option<&str> + .map(ToString::to_string) // Option + .or_else(|| { + // fallback to guess + mime_guess::from_path(resource_url).first_raw().map(ToString::to_string) + }) +} + +#[cfg(windows)] +async fn same_windows_file_identity(opened_file: &tokio::fs::File, canonical_path: &Path) -> std::io::Result { + let canonical_file = tokio::fs::File::open(canonical_path).await?; + Ok(windows_file_identity(opened_file)? == windows_file_identity(&canonical_file)?) +} + +#[cfg(windows)] +fn windows_file_identity(file: &tokio::fs::File) -> std::io::Result<(u32, u32, u32)> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION}; + + let mut info = unsafe { std::mem::zeroed::() }; + // SAFETY: `file.as_raw_handle()` is a live file handle for the duration of + // the call, and `info` is a writable output buffer for the WinAPI function. + let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &raw mut info) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok((info.dwVolumeSerialNumber, info.nFileIndexHigh, info.nFileIndexLow)) +} + +async fn build_resource_stream_response( + app_state: &Arc, + resource_url: &str, + response: reqwest::Response, +) -> axum::response::Response { + let sanitized_resource_url = sanitize_sensitive_info(resource_url); + let status = response.status(); + let mut response_builder = axum::response::Response::builder().status(status); + let mime_type = get_mime_type(response.headers(), resource_url); + let has_content_range = response.headers().contains_key(header::CONTENT_RANGE); + for (key, value) in response.headers() { + if !is_hop_by_hop_response_header(key) { + response_builder = response_builder.header(key, value); + } + } + + if !response_builder.headers_ref().is_some_and(|h| h.contains_key(header::CACHE_CONTROL)) { + response_builder = response_builder.header(header::CACHE_CONTROL, "public, max-age=14400"); + } + + let byte_stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); + // Cache only complete responses (200 OK without Content-Range) + let can_cache = status == StatusCode::OK && !has_content_range; + if can_cache { + debug!("Caching eligible resource stream {sanitized_resource_url}"); + let cache_resource_path = if let Some(cache) = app_state.cache.load().as_ref() { + Some(cache.write().await.store_path(resource_url, mime_type.as_deref())) + } else { + None + }; + if let Some(resource_path) = cache_resource_path { + match create_new_file_for_write(&resource_path).await { + Ok(file) => { + debug!("Persisting resource stream {sanitized_resource_url} to {}", resource_path.display()); + let writer = async_file_writer(file); + let add_cache_content = get_add_cache_content(resource_url, mime_type, &app_state.cache); + let tee = tee_stream(byte_stream, writer, &resource_path, add_cache_content); + return try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(tee))); + } + Err(err) => { + warn!( + "Failed to create cache file {} for {sanitized_resource_url}: {err}", + resource_path.display() + ); + } + } + } else { + debug!("Resource cache unavailable; streaming response for {sanitized_resource_url} without persistence"); + } + } + + try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(byte_stream))) +} + +async fn fetch_resource_with_retry( + app_state: &Arc, + url: &Url, + resource_url: &str, + req_headers: &HashMap>, + input: Option<&ConfigInput>, +) -> Option { + let config = app_state.app_config.config.load(); + let default_user_agent = config.default_user_agent.clone(); + drop(config); + + let disabled_headers = app_state.get_disabled_headers(); + + let provider_config = input.and_then(|i| i.get_resolve_provider(url.as_str())); + let Ok(response) = + send_with_retry_and_provider(&app_state.app_config, url, provider_config.as_ref(), false, |resolved_url| { + request::get_client_request( + &app_state.http_client.load(), + input.map_or(InputFetchMethod::GET, |i| i.method), + input.map(|i| &i.headers), + resolved_url, + Some(req_headers), + disabled_headers.as_ref(), + default_user_agent.as_deref(), + ) + }) + .await + else { + return None; + }; + + let status = response.status(); + + if status.is_success() { + return Some(build_resource_stream_response(app_state, resource_url, response).await); + } + + // Non-retriable Status -> Upstream Response incl. Body + debug_if_enabled!("Failed to open resource got status {status} for {}", sanitize_sensitive_info(resource_url)); + + let mut response_builder = axum::response::Response::builder().status(status); + for (key, value) in response.headers() { + if !is_hop_by_hop_response_header(key) { + response_builder = response_builder.header(key, value); + } + } + + let stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); + + Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream)))) +} + +/// # Panics +pub async fn resource_response( + app_state: &Arc, + resource_url: &str, + req_headers: &HeaderMap, + input: Option<&ConfigInput>, +) -> impl IntoResponse + Send { + if resource_url.is_empty() { + return StatusCode::NO_CONTENT.into_response(); + } + if resource_url.starts_with("media-server://image/") { + return match open_media_server_image_resource(app_state, resource_url).await { + Ok(response) => response, + Err(err) => { + let status = media_server_image_error_status(&err); + match status { + StatusCode::BAD_REQUEST => warn!("Invalid media-server image resource URL: {err}"), + StatusCode::NOT_FOUND => debug!("Media-server image resource was not found: {err}"), + _ => error!("Can't open media-server image from upstream: {err}"), + } + status.into_response() + } + }; + } + let filter: HeaderFilter = Some(Box::new(|key| key != "if-none-match" && key != "if-modified-since")); + let req_headers = get_headers_from_request(req_headers, &filter); + if let Some(cache) = app_state.cache.load().as_ref() { + let cache_hit = { + let mut guard = cache.write().await; + guard.get_content(resource_url) + }; + + if let Some((resource_path, mime_type)) = cache_hit { + trace_if_enabled!("Responding resource from cache {}", sanitize_sensitive_info(resource_url)); + return serve_file( + &resource_path, + mime_type.unwrap_or_else(|| mime::APPLICATION_OCTET_STREAM.to_string()), + Some("public, max-age=14400"), + ) + .await + .into_response(); + } + } + trace_if_enabled!("Try to fetch resource {}", sanitize_sensitive_info(resource_url)); + if let Ok(url) = Url::parse(resource_url) { + if let Some(resp) = fetch_resource_with_retry(app_state, &url, resource_url, &req_headers, input).await { + return resp; + } + // Upstream failure after retries + return StatusCode::BAD_GATEWAY.into_response(); + } + error!("Url is malformed {}", sanitize_sensitive_info(resource_url)); + StatusCode::BAD_REQUEST.into_response() +} + +async fn open_media_server_image_resource( + app_state: &Arc, + resource_url: &str, +) -> Result, MediaServerError> { + let image_ref = parse_media_server_image_ref(resource_url)?; + let input_name = media_server_image_input_name(&image_ref); + let input = app_state.app_config.get_input_by_name(input_name).ok_or_else(|| { + MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .provider("media-server") + .detail("media-server image input was not found") + })?; + let http_client = MediaServerHttpClient::new(app_state.http_client.load().as_ref().clone()); + + let response = match input.input_type { + InputType::Plex => { + let client = input.plex_catalog_client(http_client)?; + open_media_server_proxy_image_response(&client, &image_ref).await? + } + InputType::Emby | InputType::Jellyfin => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail("media-server image proxy is not implemented for this input type")); + } + InputType::M3u + | InputType::Xtream + | InputType::M3uBatch + | InputType::XtreamBatch + | InputType::Stalker + | InputType::StalkerBatch + | InputType::Library + | InputType::Staged => { + return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail("media-server image input is not backed by a media-server input")); + } + }; + + let mut builder = Response::builder().status(response.status); + for (key, value) in &response.headers { + if !is_hop_by_hop_response_header(key) { + builder = builder.header(key, value); + } + } + let body = response.body.map_err(|err| StreamError::Stream(err.to_string())); + builder.body(Body::from_stream(body)).map_err(|err| { + MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .provider("media-server") + .detail(format!("media-server image response build failed: {err}")) + }) +} + +fn media_server_image_error_status(err: &MediaServerError) -> StatusCode { + match err.kind { + MediaServerErrorKind::MediaServerItemNotFound | MediaServerErrorKind::NoDirectPlayableMediaServerSource => { + StatusCode::NOT_FOUND + } + MediaServerErrorKind::MediaServerStreamOpenFailed if is_media_server_image_validation_error(err) => { + StatusCode::BAD_REQUEST + } + MediaServerErrorKind::MediaServerStreamOpenFailed + | MediaServerErrorKind::MediaServerAuthDenied + | MediaServerErrorKind::MediaServerUnavailable + | MediaServerErrorKind::MediaServerLibraryUnavailable + | MediaServerErrorKind::MediaServerLibraryTypeUnsupported + | MediaServerErrorKind::MediaServerCatalogDecodeFailed + | MediaServerErrorKind::MediaServerCatalogPageStalled + | MediaServerErrorKind::MediaServerCatalogIncomplete + | MediaServerErrorKind::MediaServerRateLimited + | MediaServerErrorKind::MediaServerDiscoveryFailed => StatusCode::BAD_GATEWAY, + } +} + +fn is_media_server_image_validation_error(err: &MediaServerError) -> bool { + err.detail_text().is_some_and(|detail| { + detail.contains("resource URL is not a media server image URL") + || detail.contains("media server image URL is missing required path parts") + || detail.contains("unsupported media server image URL scheme") + || detail.contains("media-server image input is not backed by a media-server input") + }) +} + +fn media_server_image_input_name(image_ref: &MediaServerImageRef) -> &Arc { + match image_ref { + MediaServerImageRef::Emby { input_name, .. } + | MediaServerImageRef::Jellyfin { input_name, .. } + | MediaServerImageRef::Plex { input_name, .. } => input_name, + } +} + +pub fn separate_number_and_remainder(input: &str) -> (&str, Option<&str>) { + input.rfind('.').map_or_else( + || (input, None), + |dot_index| { + let number_part = &input[..dot_index]; + let rest = &input[dot_index..]; + (number_part, if rest.len() < 2 { None } else { Some(rest) }) + }, + ) +} + +/// # Panics +pub fn empty_json_list_response() -> axum::response::Response { + try_unwrap_body!(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) + .body("[]".to_owned())) +} + +pub fn get_username_from_auth_header(token: &str, app_state: &Arc) -> Option { + let config = app_state.app_config.config.load(); + let web_auth_config = config.web_ui.as_ref()?.auth.as_ref()?; + // This hand-rolled its own `decode` with a bare `Validation::new`, which + // checks `exp` and nothing else - no issuer. + crate::auth::verify_token(token, web_auth_config.secret.as_bytes(), &web_auth_config.issuer) + .map(|token_data| token_data.claims.username) +} + +pub fn redirect(url: &str) -> impl IntoResponse { + try_unwrap_body!(axum::response::Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, url) + .body(Body::empty())) +} + +pub fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> bool { + // seek only for non-live streams + if cluster == XtreamCluster::Live { + return false; + } + + // seek requests contains range header + let range = req_headers.get("range").and_then(|h| h.to_str().ok()).map(ToString::to_string); + + if let Some(range) = range { + if range.starts_with("bytes=") { + return true; + } + } + false +} + +pub fn is_seekable_media_request(cluster: XtreamCluster, req_headers: &HeaderMap, extension: Option<&str>) -> bool { + !extension.is_some_and(|ext| ext.eq_ignore_ascii_case(HLS_EXT)) && is_seek_request(cluster, req_headers) +} + +pub fn bin_response(data: &T) -> impl IntoResponse + Send { + match bin_serialize(data) { + Ok(body) => ([(header::CONTENT_TYPE, CONTENT_TYPE_CBOR)], body).into_response(), + Err(_) => internal_server_error!(), + } +} + +pub fn json_response(data: &T) -> impl IntoResponse + Send { + (StatusCode::OK, axum::Json(data)).into_response() +} + +pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> impl IntoResponse + Send { + if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { + return bin_response(data).into_response(); + } + json_response(data).into_response() +} + +pub fn stream_json_or_bin_response

( + accept: Option<&str>, + data: Box + Send>, +) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { + return stream_bin_array(data); + } + stream_json_array(data) +} + +pub fn stream_json_or_bin_response_stream(accept: Option<&str>, data: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream + Send + Unpin + 'static, +{ + if accept.is_some_and(|a| a.contains(CONTENT_TYPE_CBOR)) { + return stream_bin_array_stream(data); + } + stream_json_array_stream(data) +} + +pub fn stream_json_or_bin_response_try_stream(accept: Option<&str>, data: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream> + Send + Unpin + 'static, + E: std::fmt::Display + Send + 'static, +{ + if accept.is_some_and(|value| value.contains(CONTENT_TYPE_CBOR)) { + return stream_bin_array_try_stream(data); + } + stream_json_array_try_stream(data) +} + +pub fn create_session_fingerprint( + fingerprint: &Fingerprint, + username: &str, + virtual_id: u32, + socket_bound: bool, +) -> String { + if socket_bound { + concat_string!(&fingerprint.addr.to_string(), "|", username, "|", &virtual_id.to_string()) + } else { + concat_string!(&fingerprint.key, "|", username, "|", &virtual_id.to_string()) + } +} + +pub(crate) fn create_playback_session_fingerprint( + fingerprint: &Fingerprint, + username: &str, + virtual_id: u32, + item_type: PlaylistItemType, + extension: Option<&str>, +) -> String { + // This scopes the session identity, not the session address-tracking policy. + // 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 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) +} + +pub fn create_catchup_session_key(fingerprint: &Fingerprint, username: &str, virtual_id: u32) -> String { + concat_string!("catchup|", &fingerprint.key, "|", username, "|", &virtual_id.to_string(), "|session") +} + +pub fn create_m3u_catchup_session_key( + fingerprint: &Fingerprint, + username: &str, + virtual_id: u32, + archive_discriminator: &str, +) -> String { + concat_string!( + "m3u-catchup|", + &fingerprint.key, + "|", + username, + "|", + &virtual_id.to_string(), + "|", + archive_discriminator + ) +} + +pub(crate) fn is_session_based_playback(item_type: PlaylistItemType, extension: Option<&str>) -> bool { + item_type.is_live_adaptive() || matches!(extension, Some(ext) if ext == HLS_EXT || ext == DASH_EXT) +} + +pub(crate) fn is_socket_bound_playback_session(item_type: PlaylistItemType, extension: Option<&str>) -> bool { + item_type.uses_socket_bound_session() && !is_session_based_playback(item_type, extension) +} + +fn session_reacquire_cleanup_addrs(user_session: &UserSession, current_addr: &SocketAddr) -> Vec { + let mut addrs: SmallVec<[SocketAddr; 4]> = SmallVec::new(); + if user_session.addr != *current_addr { + addrs.push(user_session.addr); + } + for addr in &user_session.active_addrs { + if *addr != *current_addr && !addrs.contains(addr) { + addrs.push(*addr); + } + } + addrs.into_vec() +} + +pub(crate) fn should_allow_exhausted_shared_reconnect( + share_stream: bool, + user_session: Option<&UserSession>, + requested_virtual_id: u32, + requested_stream_url: &str, +) -> bool { + share_stream + && user_session.is_some_and(|session| { + session.permission != UserConnectionPermission::Exhausted + && session.virtual_id == requested_virtual_id + && session.stream_url.as_ref() == requested_stream_url + }) +} + +pub fn stream_json_array

(iter: Box + Send>) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + let stream = stream::unfold((iter, true), |(mut iter, first)| async move { + match iter.next() { + Some(item) => { + let mut json = String::new(); + if !first { + json.push(','); + } + let element = serde_json::to_string(&item).ok()?; + json.push_str(&element); + Some((Ok::(Bytes::from(json)), (iter, false))) + } + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) + .chain(stream) + .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"]")) })), + )); + + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) +} + +pub fn stream_bin_array

(iter: Box + Send>) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, +{ + let stream = stream::unfold(iter, |mut iter| async move { + match iter.next() { + Some(item) => { + match bin_serialize(&item) { + Ok(buf) => Some((Ok::(Bytes::from(buf)), iter)), + Err(err) => { + warn!("CBOR serialization error in stream: {err}"); + Some((Ok::(Bytes::new()), iter)) // skip errors, continue + } + } + } + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { + // CBOR: start indefinite-length array + Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) + }) + .chain(stream) + .chain(stream::once(async { + // CBOR: end indefinite-length array + Ok::<_, Infallible>(Bytes::from_static(&[0xff])) + })), + )); + + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) +} + +pub fn stream_json_array_stream(stream: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream + Send + Unpin + 'static, +{ + let stream = stream::unfold((stream, true), |(mut stream, first)| async move { + match stream.next().await { + Some(item) => { + let mut json = String::new(); + if !first { + json.push(','); + } + let element = serde_json::to_string(&item).ok()?; + json.push_str(&element); + Some((Ok::(Bytes::from(json)), (stream, false))) + } + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) + .chain(stream) + .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"]")) })), + )); + + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) +} + +fn stream_json_array_try_stream(stream: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream> + Send + Unpin + 'static, + E: std::fmt::Display + Send + 'static, +{ + let stream = stream::unfold((stream, true, false), |(mut stream, first, failed)| async move { + if failed { + return None; + } + match stream.next().await { + Some(Ok(item)) => { + let serialized = serde_json::to_vec(&item).map_err(|error| error.to_string()); + let bytes = serialized.map(|serialized| { + if first { + Bytes::from(serialized) + } else { + let mut framed = Vec::with_capacity(serialized.len() + 1); + framed.push(b','); + framed.extend_from_slice(&serialized); + Bytes::from(framed) + } + }); + let failed = bytes.is_err(); + Some((bytes, (stream, false, failed))) + } + Some(Err(error)) => Some((Err(error.to_string()), (stream, first, true))), + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { Ok::<_, String>(Bytes::from_static(b"[")) }) + .chain(stream) + .chain(stream::once(async { Ok::<_, String>(Bytes::from_static(b"]")) })), + )); + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_JSON).body(body)) +} + +pub fn stream_bin_array_stream(stream: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream + Send + Unpin + 'static, +{ + let stream = stream::unfold(stream, |mut stream| async move { + match stream.next().await { + Some(item) => match bin_serialize(&item) { + Ok(buf) => Some((Ok::(Bytes::from(buf)), stream)), + Err(err) => { + warn!("CBOR serialization error in stream: {err}"); + Some((Ok::(Bytes::new()), stream)) + } + }, + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) }) + .chain(stream) + .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(&[0xff])) })), + )); + + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) +} + +fn stream_bin_array_try_stream(stream: S) -> axum::response::Response +where + P: serde::Serialize + Send + 'static, + S: Stream> + Send + Unpin + 'static, + E: std::fmt::Display + Send + 'static, +{ + let stream = stream::unfold((stream, false), |(mut stream, failed)| async move { + if failed { + return None; + } + match stream.next().await { + Some(Ok(item)) => { + let bytes = bin_serialize(&item).map(Bytes::from).map_err(|error| error.to_string()); + let failed = bytes.is_err(); + Some((bytes, (stream, failed))) + } + Some(Err(error)) => Some((Err(error.to_string()), (stream, true))), + None => None, + } + }); + + let body = Body::from_stream(coalesce_byte_stream( + stream::once(async { Ok::<_, String>(Bytes::from_static(&[0x9f])) }) + .chain(stream) + .chain(stream::once(async { Ok::<_, String>(Bytes::from_static(&[0xff])) })), + )); + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) +} + +const API_STREAM_CHUNK_SIZE: usize = 64 * 1024; + +pub(crate) fn coalesce_byte_stream(stream: S) -> impl Stream> +where + S: Stream> + Send + 'static, + E: Send + 'static, +{ + stream::unfold((Box::pin(stream), None, false), |(mut stream, pending_error, finished)| async move { + if let Some(error) = pending_error { + return Some((Err(error), (stream, None, true))); + } + if finished { + return None; + } + + let mut chunk = BytesMut::with_capacity(API_STREAM_CHUNK_SIZE); + loop { + match stream.next().await { + Some(Ok(bytes)) if chunk.is_empty() && bytes.len() >= API_STREAM_CHUNK_SIZE => { + return Some((Ok(bytes), (stream, None, false))); + } + Some(Ok(bytes)) => { + chunk.extend_from_slice(&bytes); + if chunk.len() >= API_STREAM_CHUNK_SIZE { + return Some((Ok(chunk.freeze()), (stream, None, false))); + } + } + Some(Err(error)) if chunk.is_empty() => { + return Some((Err(error), (stream, None, true))); + } + Some(Err(error)) => { + return Some((Ok(chunk.freeze()), (stream, Some(error), false))); + } + None if chunk.is_empty() => return None, + None => return Some((Ok(chunk.freeze()), (stream, None, true))), + } + } + }) + .fuse() +} + +pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials { + let config = app_state.app_config.config.load(); + + let server = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.player_server.as_ref()) + .map_or("default", |server_name| server_name.as_str()); + + ProxyUserCredentials { + username: "api_user".to_string(), + password: "api_user".to_string(), + token: None, + proxy: ProxyType::Reverse(None), + server: Some(server.to_string()), + epg_timeshift: None, + epg_request_timeshift: None, + created_at: None, + exp_date: None, + max_connections: 0, + status: None, + output_clusters: shared::model::ClusterFlags::all(), + ui_enabled: false, + comment: None, + priority: 0, + soft_connections: 0, + soft_priority: 0, + t_is_api_user: true, + network_access: None, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 0, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::Reverse(None)), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, + } +} + +pub fn empty_json_response_as_object() -> axum::http::Result { + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) + .body(axum::body::Body::from("{}".as_bytes())) +} + +pub fn empty_json_response_as_array() -> axum::http::Result { + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, crate::api::static_headers::CT_JSON.clone()) + .body(axum::body::Body::from("[]".as_bytes())) +} + +#[cfg(test)] +mod tests; diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs new file mode 100644 index 000000000..b0e6d9cc5 --- /dev/null +++ b/backend/app/src/api/api_utils/tests.rs @@ -0,0 +1,5442 @@ +use super::*; +use crate::{ + api::model::{ + ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, EventManager, + MetadataUpdateManager, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, ProviderConfigConnection, + SharedStreamManager, + }, + auth::Fingerprint, + model::{ + AppConfig, Config, ConfigInput, ConfigInputAlias, ConfigProvider, ConfigTarget, GracePeriodOptions, + MediaToolCapabilities, NetworkAccess, ProcessTargets, ProxyUserCredentials, SourcesConfig, StreamHistoryConfig, + }, + repository::GeoIp, + utils::FileLockManager, +}; +use arc_swap::{ArcSwap, ArcSwapOption}; +use axum::http::{HeaderMap, Response, StatusCode}; +use bytes::Bytes; +use futures::stream; +use http_body_util::BodyExt; +use shared::{ + defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs}, + foundation::Filter, + model::{ + AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigProviderDto, ConfigTargetOptions, GeoIpUnavailablePolicy, + InputFetchMethod, InputType, PlaylistItemType, ProcessingOrder, ProviderUrlSelectionPolicy, ProxyType, + StreamChannel, XtreamCluster, + }, + utils::Internable, +}; +use std::{borrow::Cow, collections::HashMap, net::SocketAddr, sync::Arc}; +use tokio::{ + io::AsyncWriteExt, + net::TcpListener, + sync::{mpsc, RwLock}, +}; +use tuliprox_core::utils::response_compression::should_compress_response; +use tuliprox_session::{ + admission::{ + evaluate_remaining_strategies_after_grace, get_effective_admission_strategies, RECENT_EVICTION_REENTRY_TTL_SECS, + }, + GraceResolutionContext, +}; + +#[test] +fn stalker_playback_refreshes_invalid_or_rejected_urls() { + assert!(should_refresh_stalker_playback(InputType::Stalker, false, None)); + assert!(should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::UNAUTHORIZED))); + assert!(!should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::OK))); + assert!(!should_refresh_stalker_playback(InputType::Xtream, false, None)); +} + +#[test] +fn initial_stalker_playback_resolves_only_empty_urls() { + assert!(needs_initial_stalker_resolution(InputType::Stalker, "")); + assert!(!needs_initial_stalker_resolution(InputType::Stalker, "https://stream.example/live.ts")); + assert!(!needs_initial_stalker_resolution(InputType::Xtream, "")); + assert_eq!(stalker_stream_kind(XtreamCluster::Live, PlaylistItemType::Catchup), StalkerStreamKind::Archive); +} + +fn test_runtime_provider(url: &str, username: &str, password: &str) -> Arc { + test_runtime_provider_with_type(url, username, password, InputType::Xtream) +} + +#[tokio::test] +async fn streamed_json_array_coalesces_small_entries() { + let response = stream_json_array_stream(stream::iter(0..4_096u32)); + let mut body = response.into_body(); + let mut frames = 0usize; + let mut bytes = Vec::new(); + while let Some(frame) = body.frame().await { + let Ok(frame) = frame else { + return; + }; + if let Ok(data) = frame.into_data() { + frames += 1; + bytes.extend_from_slice(&data); + } + } + assert!(frames <= 2, "small JSON entries should be coalesced, got {frames} frames"); + let decoded = serde_json::from_slice::>(&bytes); + assert!(decoded.is_ok_and(|values| values.len() == 4_096)); +} + +#[tokio::test] +async fn catchup_payload_probe_detects_fragmented_hls() { + let source = + stream::iter([Ok::<_, StreamError>(Bytes::from_static(b"#EX")), Ok(Bytes::from_static(b"TM3U\nsegment.ts\n"))]) + .boxed(); + + let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; + + assert!(matches!(&result, Ok(CatchupPayload::HlsManifest(_)))); + if let Ok(CatchupPayload::HlsManifest(manifest)) = result { + assert_eq!(manifest, b"#EXTM3U\nsegment.ts\n".as_slice()); + } +} + +#[tokio::test] +async fn catchup_payload_probe_replays_ts_bytes() { + let expected = Bytes::from_static(b"\x47direct-ts-payload"); + let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed(); + + let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; + + assert!(matches!(&result, Ok(CatchupPayload::Direct(_)))); + if let Ok(CatchupPayload::Direct(mut stream)) = result { + let mut actual = Vec::new(); + while let Some(chunk) = stream.next().await { + if let Ok(chunk) = chunk { + actual.extend_from_slice(&chunk); + } + } + assert_eq!(actual, expected.as_ref()); + } +} + +#[tokio::test] +async fn catchup_payload_probe_replays_partial_signature_at_eof() { + let expected = Bytes::from_static(b"#EXT"); + let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed(); + + let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; + + assert!(matches!(&result, Ok(CatchupPayload::Direct(_)))); + if let Ok(CatchupPayload::Direct(mut stream)) = result { + let actual = stream.next().await.and_then(Result::ok); + assert_eq!(actual.as_ref(), Some(&expected)); + assert!(stream.next().await.is_none()); + } +} + +#[tokio::test] +async fn catchup_payload_probe_rejects_oversized_manifest() { + let oversized = vec![b'x'; MAX_HLS_MANIFEST_BYTES]; + let source = + stream::iter([Ok::<_, StreamError>(Bytes::from_static(b"#EXTM3U")), Ok(Bytes::from(oversized))]).boxed(); + + let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn coalesced_stream_remains_finished_when_polled_again() { + let stream = coalesce_byte_stream(stream::empty::>()); + futures::pin_mut!(stream); + + assert!(stream.next().await.is_none()); + assert!(stream.next().await.is_none()); +} + +fn test_runtime_provider_with_type( + url: &str, + username: &str, + password: &str, + input_type: InputType, +) -> Arc { + let url = if input_type == InputType::M3u { + format!("{url}/playlist.m3u8?username={username}&password={password}") + } else { + url.to_string() + }; + let input = ConfigInput { + name: "provider".intern(), + url, + username: Some(username.to_string()), + password: Some(password.to_string()), + input_type, + ..ConfigInput::default() + }; + Arc::new(RuntimeProviderConfig::new( + &input, + Arc::new(RwLock::new(ProviderConfigConnection::default())), + Arc::new(|_, _| {}), + )) +} + +fn test_runtime_provider_without_credentials(url: &str, input_type: InputType) -> Arc { + let input = ConfigInput { name: "provider".intern(), url: url.to_string(), input_type, ..ConfigInput::default() }; + Arc::new(RuntimeProviderConfig::new( + &input, + Arc::new(RwLock::new(ProviderConfigConnection::default())), + Arc::new(|_, _| {}), + )) +} + +#[test] +fn test_is_seek_request() { + let mut headers = HeaderMap::new(); + + // No range header + assert!(!is_seek_request(XtreamCluster::Video, &headers)); + + // Range: bytes=0- (Should be true now to allow session takeover on restart) + headers.insert("range", "bytes=0-".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers)); + + // Range: bytes=100- (Should be true) + headers.insert("range", "bytes=100-".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers)); + + // Range: bytes=100-200 (Should be true) + headers.insert("range", "bytes=100-200".parse().unwrap()); + assert!(is_seek_request(XtreamCluster::Video, &headers)); + + // Live cluster should always return false + headers.insert("range", "bytes=100-".parse().unwrap()); + assert!(!is_seek_request(XtreamCluster::Live, &headers)); +} + +#[test] +fn hls_manifests_are_not_forced_as_seek_responses() { + let mut headers = HeaderMap::new(); + headers.insert("range", HeaderValue::from_static("bytes=0-")); + + assert!(!is_seekable_media_request(XtreamCluster::Video, &headers, Some(HLS_EXT))); + assert!(is_seekable_media_request(XtreamCluster::Video, &headers, Some(".ts"))); +} + +#[test] +fn resolve_redirect_location_resolves_provider_scheme_urls() { + let provider = ConfigProvider::from(&ConfigProviderDto { + name: "develop".intern(), + urls: vec!["https://provider.example".intern()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + }); + let input = ConfigInput { + name: "provider".intern(), + provider_configs: Some(vec![Arc::new(provider)]), + ..ConfigInput::default() + }; + + let resolved = + resolve_redirect_location(Some(&input), "provider://develop/live/provider-user/provider-pass/33486.m3u8") + .expect("provider url should resolve"); + + assert_eq!(resolved, "https://provider.example/live/provider-user/provider-pass/33486.m3u8"); +} + +#[test] +fn stream_alternative_url_keeps_unmatched_urls_unchanged() { + let input = ConfigInput { + name: "source".intern(), + url: "http://source.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://other.example/live/source-user/source-pass/123.ts"; + + let rewritten = get_stream_alternative_url(stream_url, &input, &alias); + + assert_eq!(rewritten, None); +} + +#[test] +fn stream_alternative_url_rewrites_only_query_auth_fields() { + let input = ConfigInput { + name: "source".intern(), + url: "http://source.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://source.example/player?token=source-user&username=source-user&password=source-pass"; + + let rewritten = get_stream_alternative_url(stream_url, &input, &alias); + + assert_eq!( + rewritten, + Some("http://alias.example/player?token=source-user&username=alias-user&password=alias-pass".to_string()) + ); +} + +#[test] +fn stream_url_matches_provider_requires_base_url_and_account_identity() { + let provider = test_runtime_provider("http://same.example", "selected-user", "selected-pass"); + + assert!(stream_url_matches_provider("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] +fn stream_url_matches_provider_accepts_external_playlist_url_for_m3u_without_account_signature() { + let provider = + test_runtime_provider_with_type("http://provider.example", "selected-user", "selected-pass", InputType::M3u); + + assert!(stream_url_matches_provider( + "https://hlspackager.akamaized.net/live/DB/ALYAUM_TV/HLS/ALYAUM_TV.m3u8", + &provider + )); + assert!(stream_url_matches_provider( + "https://shd-gcp-live.edgenextcdn.net/live/bitmovin-mbc-1/15cf99af5de54063fdabfefe66adc075/index.m3u8", + &provider + )); +} + +#[test] +fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature() { + let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass"); + + assert!(!stream_url_matches_provider("http://cdn.example/live/other-user/other-pass/123.ts", &provider)); + assert!(!stream_url_matches_provider( + "http://cdn.example/segment.ts?username=other-user&password=other-pass", + &provider + )); +} + +#[test] +fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature_for_m3u() { + let provider = + test_runtime_provider_with_type("http://provider.example", "selected-user", "selected-pass", InputType::M3u); + + assert!(!stream_url_matches_provider( + "http://cdn.example/segment.ts?username=other-user&password=other-pass", + &provider + )); +} + +#[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"); + + assert!(!stream_url_matches_provider("http://cdn.example/open/playlist.m3u8", &provider)); + assert!(!stream_url_matches_provider("http://cdn.example/open/segment.ts?key=signedopaque", &provider)); +} + +#[test] +fn stream_url_matches_provider_rejects_external_cdn_url_for_xtream_even_with_valid_account_signature() { + let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass"); + + assert!(!stream_url_matches_provider("http://cdn.example/live/selected-user/selected-pass/123.ts", &provider)); + assert!(!stream_url_matches_provider( + "http://cdn.example/segment.ts?username=selected-user&password=selected-pass", + &provider + )); +} + +#[test] +fn find_input_account_by_signature_matches_main_input_and_alias_accounts() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example".to_string(), + username: Some("main-user".to_string()), + password: Some("main-pass".to_string()), + input_type: InputType::Xtream, + aliases: Some(vec![ConfigInputAlias { + id: 2, + name: "alias".intern(), + url: "http://alias.example".to_string(), + username: Some("alias-user".to_string()), + password: Some("alias-pass".to_string()), + max_connections: 1, + priority: 0, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }; + + let main = find_input_account_by_signature("http://cdn.example/live/main-user/main-pass/1.ts", &input); + assert_eq!( + main, + Some(("http://provider.example".to_string(), Some("main-user".to_string()), Some("main-pass".to_string()),)) + ); + + let alias = find_input_account_by_signature("http://cdn.example/live/alias-user/alias-pass/1.ts", &input); + assert_eq!( + alias, + Some(("http://alias.example".to_string(), Some("alias-user".to_string()), Some("alias-pass".to_string()),)) + ); + + assert_eq!(find_input_account_by_signature("http://cdn.example/live/other/other/1.ts", &input), None); + assert_eq!(find_input_account_by_signature("http://cdn.example/open/playlist.m3u8", &input), None); +} + +#[test] +fn get_stream_alternative_url_rewrites_external_cdn_url_with_valid_account_signature_for_alias_account() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); + let stream_url = "http://cdn.example/live/source-user/source-pass/123.ts"; + + let rewritten = get_stream_alternative_url(stream_url, &input, &alias); + assert_eq!(rewritten, Some("http://cdn.example/live/alias-user/alias-pass/123.ts".to_string())); +} + +#[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 { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); + let stream_url = "https://cnbc-live.akamaized.net/cnbc/master.m3u8"; + + assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string())); +} + +#[test] +fn get_stream_alternative_url_keeps_open_external_multisegment_playlist_url_for_m3u() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); + let stream_url = "https://hnpsechtsc.turknet.ercdn.net/xpnvudnlsv/cnbc-e/cnbc-e.m3u8"; + + assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string())); +} + +#[test] +fn get_stream_alternative_url_keeps_open_external_m3u_url_for_provider_without_credentials() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8".to_string(), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); + let stream_url = "http://s.only4.tv/17113/video.m3u8?token=abc"; + + assert_eq!(get_stream_alternative_url(stream_url, &input, &provider), Some(stream_url.to_string())); +} + +#[test] +fn get_stream_alternative_url_rejects_query_credentials_for_provider_without_credentials() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8".to_string(), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); + + assert_eq!( + get_stream_alternative_url( + "http://cdn.example/segment.ts?username=other-user&password=other-pass", + &input, + &provider + ), + None + ); +} + +#[test] +fn get_stream_alternative_url_rejects_basic_auth_credentials_for_provider_without_credentials() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8".to_string(), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u); + + assert_eq!(get_stream_alternative_url("http://user:pass@cdn.example/segment.ts", &input, &provider), None); +} + +#[test] +fn get_stream_alternative_url_rejects_external_m3u_url_with_unmatched_account_signature() { + let input = ConfigInput { + name: "source".intern(), + url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u); + + assert_eq!( + get_stream_alternative_url( + "http://cdn.example/segment.ts?username=other-user&password=other-pass", + &input, + &alias, + ), + None + ); +} + +#[test] +fn get_stream_alternative_url_does_not_passthrough_arbitrary_open_external_url_for_xtream() { + 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://cdn.example/open/playlist.m3u8"; + + assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), None); +} + +#[test] +fn media_server_proxy_response_header_filter_drops_hop_by_hop_headers() { + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "trailers", + "transfer-encoding", + "upgrade", + ] { + assert!(is_hop_by_hop_response_header(&HeaderName::from_static(name))); + } + assert!(!is_hop_by_hop_response_header(&header::CONTENT_TYPE)); +} + +#[test] +fn media_server_image_error_status_classifies_client_and_upstream_failures() { + let parse_error = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .detail("media server image URL is missing required path parts"); + assert_eq!(media_server_image_error_status(&parse_error), StatusCode::BAD_REQUEST); + + let not_found = MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound) + .detail("plex media-server image URL is missing image_path"); + assert_eq!(media_server_image_error_status(¬_found), StatusCode::NOT_FOUND); + + let upstream = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) + .detail("media-server image request failed"); + assert_eq!(media_server_image_error_status(&upstream), StatusCode::BAD_GATEWAY); +} + +#[test] +fn media_server_playback_urls_are_proxy_only_redirect_guard_candidates() { + let plex_input = ConfigInput { input_type: InputType::Plex, ..ConfigInput::default() }; + let emby_input = ConfigInput { input_type: InputType::Emby, ..ConfigInput::default() }; + let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() }; + + assert!(is_media_server_playback_url( + &plex_input, + "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" + )); + assert!(is_media_server_playback_url( + &m3u_input, + "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" + )); + assert!(is_media_server_playback_url(&plex_input, "https://plex.example/stream.mkv")); + assert!(!is_media_server_playback_url(&emby_input, "https://emby.example/stream.mkv")); + assert!(!is_media_server_playback_url(&m3u_input, "https://provider.example/stream.mkv")); + assert!(!is_media_server_stream_ref_url("https://provider.example/stream.mkv")); + assert!(is_media_server_stream_ref_url("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted")); + assert_eq!( + resolve_request_url_for_logging( + &plex_input, + "media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted" + ) + .as_ref(), + "media-server://" + ); +} + +#[test] +fn test_streaming_response_extension_disables_compression() { + let mut response = Response::new(()); + mark_response_as_uncompressed(&mut response); + + assert!(!should_compress_response(&response)); +} + +async fn spawn_legacy_hls_test_origin( + response_head: String, + response_body: Vec, +) -> (SocketAddr, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); + let origin_addr = listener.local_addr().expect("test origin address"); + let origin_task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("test origin accepts request"); + let mut request = Vec::new(); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let mut chunk = [0_u8; 1024]; + let read = socket.read(&mut chunk).await.expect("test origin reads request"); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + } + socket.write_all(response_head.as_bytes()).await.expect("test origin writes response headers"); + socket.write_all(&response_body).await.expect("test origin writes response body"); + String::from_utf8_lossy(&request).into_owned() + }); + (origin_addr, origin_task) +} + +async fn forced_legacy_hls_test_response( + origin_addr: SocketAddr, + request_headers: &HeaderMap, + client_port: u16, +) -> axum::response::Response { + let origin_url = format!("http://{origin_addr}/segment.ts"); + let input = Arc::new(ConfigInput { + id: 1, + name: "provider_1".intern(), + input_type: InputType::Xtream, + headers: HashMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]), + url: format!("http://{origin_addr}"), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + ..ConfigInput::default() + }); + let app_config = create_test_provider_app_config(); + app_config.sources.store(Arc::new(SourcesConfig { inputs: vec![Arc::clone(&input)], ..SourcesConfig::default() })); + let app_state = create_test_app_state_for_config(Arc::new(app_config)); + let client_addr = SocketAddr::from(([127, 0, 0, 1], client_port)); + let fingerprint = create_test_fingerprint(client_addr); + let mut user = ProxyUserCredentials::default(); + user.username = "viewer".to_string(); + let session = UserSession { + token: format!("legacy-hls-marker-{client_port}"), + transition_version: 1, + virtual_id: 41, + provider: Arc::clone(&input.name), + stream_url: origin_url.as_str().intern(), + provider_session_headers: HashMap::new(), + addr: client_addr, + socket_bound: false, + active_addrs: vec![client_addr], + ts: 1, + started_at: 1, + permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + lifecycle: crate::api::model::PlaybackLifecycle::Active, + }; + let mut stream_channel = create_test_local_channel(&origin_url); + stream_channel.provider_id = u32::from(input.id); + stream_channel.input_name = Arc::clone(&input.name); + stream_channel.item_type = PlaylistItemType::Catchup; + stream_channel.cluster = XtreamCluster::Live; + stream_channel.url = origin_url.as_str().intern(); + + force_provider_stream_response( + &fingerprint, + &app_state, + &session, + stream_channel, + ForceStreamRequestContext { + req_headers: request_headers, + input: &input, + user: &user, + session_reservation_ttl_secs: 0, + content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, + }, + None, + ) + .await + .into_response() +} + +#[tokio::test] +async fn forced_hls_provider_response_disables_compression_and_streams_identity_bytes() { + const IDENTITY_BODY: &[u8] = b"legacy hls identity segment"; + + let mut encoder = async_compression::tokio::write::GzipEncoder::new(Vec::new()); + encoder.write_all(IDENTITY_BODY).await.expect("gzip test body encodes"); + encoder.shutdown().await.expect("gzip test encoder finishes"); + let encoded_body = encoder.into_inner(); + + let response_head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + encoded_body.len() + ); + let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, encoded_body).await; + let mut request_headers = HeaderMap::new(); + request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_310).await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(!should_compress_response(&response)); + assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); + assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); + let body = response.into_body().collect().await.expect("legacy HLS response body").to_bytes(); + assert_eq!(body.as_ref(), IDENTITY_BODY); + + let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase(); + assert!(request.contains("\r\naccept-encoding: identity\r\n")); +} + +#[tokio::test] +async fn forced_hls_unencoded_partial_response_preserves_range_and_disables_compression() { + const PARTIAL_BODY: &[u8] = b"cdef"; + let response_head = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Type: video/mp2t\r\nContent-Range: bytes 2-5/10\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + PARTIAL_BODY.len() + ); + let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, PARTIAL_BODY.to_vec()).await; + let mut request_headers = HeaderMap::new(); + request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=2-")); + + let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_311).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert!(!should_compress_response(&response)); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); + assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); + let body = response.into_body().collect().await.expect("legacy HLS partial body").to_bytes(); + assert_eq!(body.as_ref(), PARTIAL_BODY); + + let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase(); + assert!(request.contains("\r\naccept-encoding: identity\r\n")); + assert!(request.contains("\r\nrange: bytes=2-\r\n")); +} + +#[test] +fn test_regular_response_keeps_compression_enabled() { + let response = Response::new(()); + + assert!(should_compress_response(&response)); +} + +#[test] +fn test_get_stream_config_u64_uses_default_when_stream_config_missing() { + assert_eq!( + resolve_stream_config_u64(None, |stream| stream.hls_session_ttl_secs, default_hls_session_ttl_secs()), + default_hls_session_ttl_secs() + ); + assert_eq!( + resolve_stream_config_u64(None, |stream| stream.catchup_session_ttl_secs, default_catchup_session_ttl_secs()), + default_catchup_session_ttl_secs() + ); +} + +#[tokio::test] +async fn test_get_session_reservation_ttl_secs_uses_hls_ttl_for_live_dash() { + let app_state = create_test_app_state(); + assert_eq!( + get_session_reservation_ttl_secs(&app_state, PlaylistItemType::LiveDash), + default_hls_session_ttl_secs() + ); +} + +#[test] +fn provider_affinity_policy_matches_stream_types() { + assert!(!PlaylistItemType::Live.requires_provider_affinity()); + assert!(!PlaylistItemType::LiveUnknown.requires_provider_affinity()); + assert!(PlaylistItemType::LiveHls.requires_provider_affinity()); + assert!(PlaylistItemType::LiveDash.requires_provider_affinity()); + assert!(PlaylistItemType::Video.requires_provider_affinity()); + assert!(PlaylistItemType::Series.requires_provider_affinity()); + assert!(PlaylistItemType::Catchup.requires_provider_affinity()); +} + +#[tokio::test] +async fn resolve_streaming_strategy_honors_forced_provider_fallback_policy() { + 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 pinned_provider = "provider_1".intern(); + let busy_addr: SocketAddr = "127.0.0.1:55301".parse().unwrap_or_else(|_| unreachable!()); + let strict_addr: SocketAddr = "127.0.0.1:55302".parse().unwrap_or_else(|_| unreachable!()); + let fallback_addr: SocketAddr = "127.0.0.1:55303".parse().unwrap_or_else(|_| unreachable!()); + let stream_url = "http://provider-1.example/movie/user1/pass1/1.mkv"; + + let busy = app_state + .active_provider + .acquire_exact_connection_with_grace( + &pinned_provider, + &busy_addr, + false, + 0, + crate::api::model::ConnectionKind::Normal, + ) + .await; + assert!(busy.is_some(), "setup should occupy the pinned provider"); + + let strict = resolve_streaming_strategy( + &app_state, + stream_url, + &create_test_fingerprint(strict_addr), + &input, + StreamingAcquireOptions { + force_provider: Some(&pinned_provider), + allow_forced_provider_fallback: false, + allow_provider_grace: false, + user_priority: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + session_owner: Some("vod-session"), + accept_requested_stream_url: false, + }, + ) + .await; + assert!(strict.provider_handle.is_none(), "strict provider affinity should not allocate a different provider"); + assert!( + matches!( + strict.provider_stream_state, + ProviderStreamState::Custom { reason: ProviderStreamCustomReason::ProviderExhausted, .. } + ), + "strict provider affinity should fail closed when the pinned provider is unavailable" + ); + + let fallback = resolve_streaming_strategy( + &app_state, + stream_url, + &create_test_fingerprint(fallback_addr), + &input, + StreamingAcquireOptions { + force_provider: Some(&pinned_provider), + allow_forced_provider_fallback: true, + allow_provider_grace: false, + user_priority: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + session_owner: Some("live-session"), + accept_requested_stream_url: false, + }, + ) + .await; + let (ProviderStreamState::Available(Some(fallback_provider), _) + | ProviderStreamState::GracePeriod(Some(fallback_provider), _)) = fallback.provider_stream_state + else { + panic!("fallback-enabled request should allocate a provider") + }; + assert_eq!(fallback_provider.as_ref(), "provider_2"); + + app_state.active_provider.release_connection(&busy_addr).await; + app_state.active_provider.release_connection(&strict_addr).await; + app_state.active_provider.release_connection(&fallback_addr).await; +} + +#[tokio::test] +async fn resolve_streaming_strategy_rewrites_stale_alias_url_to_selected_main_provider() { + 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:55304".parse().unwrap_or_else(|_| unreachable!()); + + let strategy = resolve_streaming_strategy( + &app_state, + "http://provider-2.example/live/user2/pass2/100.ts", + &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: false, + }, + ) + .await; + + let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else { + panic!("request should allocate the main provider") + }; + assert_eq!(provider.as_ref(), "provider_1"); + assert_eq!(url.as_ref(), "http://provider-1.example/live/user1/pass1/100.ts"); + + app_state.active_provider.release_connection(&addr).await; +} + +#[tokio::test] +async fn resolve_streaming_strategy_rejects_unmapped_provider_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:55305".parse().unwrap_or_else(|_| unreachable!()); + + let strategy = resolve_streaming_strategy( + &app_state, + "http://unmapped.example/live/user1/pass1/100.ts", + &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: false, + }, + ) + .await; + + assert!(strategy.provider_handle.is_none()); + assert!(matches!( + strategy.provider_stream_state, + ProviderStreamState::Custom { reason: ProviderStreamCustomReason::UnmappedProviderUrl, .. } + )); + + app_state.active_provider.release_connection(&addr).await; +} + +#[tokio::test] +async fn resolve_streaming_strategy_accepts_stalker_portal_url() { + let app_config = create_test_provider_app_config(); + let Some(configured_input) = app_config.sources.load().inputs.first().cloned() else { unreachable!() }; + let mut stalker_input = (*configured_input).clone(); + stalker_input.input_type = InputType::Stalker; + stalker_input.username = None; + stalker_input.password = None; + app_config + .sources + .store(Arc::new(SourcesConfig { inputs: vec![Arc::new(stalker_input)], ..SourcesConfig::default() })); + let app_state = create_test_app_state_for_config(Arc::new(app_config)); + 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:55307".parse().unwrap_or_else(|_| unreachable!()); + let stream_url = "http://line.example/play/live.php?mac=00:11:22:33:44:55&stream=347&extension=ts&play_token=abc"; + + let strategy = resolve_streaming_strategy( + &app_state, + stream_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: false, + }, + ) + .await; + + let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else { unreachable!() }; + assert_eq!(provider.as_ref(), "provider_1"); + assert_eq!(url.as_ref(), stream_url); + + 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 { + transition_version: 1, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + token: "tok".to_string(), + virtual_id: 282, + provider: Arc::::from("provider"), + stream_url: Arc::::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!())], + ts: 1, + started_at: 1, + permission: UserConnectionPermission::Allowed, + lifecycle: crate::api::model::PlaybackLifecycle::Active, + }; + + assert!(should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/449924.ts")); + assert!(!should_allow_exhausted_shared_reconnect(false, Some(&session), 282, "http://provider/live/449924.ts")); + assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 999, "http://provider/live/449924.ts")); + assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/other.ts")); +} + +fn create_test_app_config() -> AppConfig { + let input = Arc::new(ConfigInput { + id: 1, + name: "local_media".intern(), + input_type: InputType::Library, + headers: HashMap::default(), + url: "file:///tmp".to_string(), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + aliases: None, + ..ConfigInput::default() + }); + let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } +} + +fn create_test_provider_app_config() -> AppConfig { + let input = Arc::new(ConfigInput { + id: 1, + name: "provider_1".intern(), + input_type: InputType::Xtream, + headers: HashMap::default(), + url: "http://provider-1.example".to_string(), + username: Some("user1".to_string()), + password: Some("pass1".to_string()), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + aliases: None, + ..ConfigInput::default() + }); + let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } +} + +fn create_test_dual_provider_app_config() -> AppConfig { + let input = Arc::new(ConfigInput { + id: 1, + name: "provider_1".intern(), + input_type: InputType::Xtream, + headers: HashMap::default(), + url: "http://provider-1.example".to_string(), + username: Some("user1".to_string()), + password: Some("pass1".to_string()), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + aliases: Some(vec![ConfigInputAlias { + id: 2, + name: "provider_2".intern(), + url: "http://provider-2.example".to_string(), + username: Some("user2".to_string()), + password: Some("pass2".to_string()), + priority: 1, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }); + let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } +} + +fn create_test_app_state() -> Arc { create_test_app_state_for_config(Arc::new(create_test_app_config())) } + +#[tokio::test] +async fn create_api_proxy_user_defaults_output_clusters_to_all() { + let app_state = create_test_app_state(); + let user = create_api_proxy_user(&app_state); + assert_eq!(user.output_clusters, ClusterFlags::all()); +} + +fn create_test_provider_app_state() -> Arc { + create_test_app_state_for_config(Arc::new(create_test_provider_app_config())) +} + +fn create_test_dual_provider_app_state() -> Arc { + create_test_app_state_for_config(Arc::new(create_test_dual_provider_app_config())) +} + +fn create_test_app_state_for_config(app_cfg: Arc) -> Arc { + let event_manager = Arc::new(EventManager::new()); + let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); + let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); + let history_config = Some(StreamHistoryConfig::default()); + active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); + + let geoip = Arc::new(ArcSwapOption::::default()); + let config = app_cfg.config.load(); + let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + let connection_manager = Arc::new(ConnectionManager::new( + &active_users, + &active_provider, + &shared_stream_manager, + &event_manager, + history_config.as_ref(), + )); + + let tokens = CancelTokens::default(); + let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); + let (manual_update_sender, _) = mpsc::channel::(1); + + Arc::new(AppState { + forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { + enabled: false, + inputs: Vec::new(), + targets: Vec::new(), + target_names: Vec::new(), + })), + app_config: app_cfg, + http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + downloads: Arc::new(crate::api::model::DownloadQueue::new()), + cache: Arc::new(ArcSwapOption::default()), + shared_stream_manager, + hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), + hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), + active_users, + active_provider, + connection_manager, + event_manager, + cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), + playlists: Arc::new(PlaylistStorageState::new()), + geoip, + update_guard: crate::api::model::UpdateGuard::new(), + metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), + manual_update_sender, + }) +} + +fn create_test_fingerprint(addr: std::net::SocketAddr) -> Fingerprint { + Fingerprint::new(format!("fp-{addr}"), addr.ip().to_string(), addr) +} + +fn create_test_fingerprint_with_user_agent(addr: std::net::SocketAddr, user_agent: &str) -> Fingerprint { + Fingerprint::new(format!("{}|{user_agent}", addr.ip()), addr.ip().to_string(), addr) +} + +fn create_test_app_state_with_stream_config(stream: crate::model::StreamConfig) -> Arc { + let config = Config { + reverse_proxy: Some(crate::model::ReverseProxyConfig { + resource_rewrite_disabled: false, + rewrite_secret: [0; 16], + resource_retry: crate::model::ResourceRetryConfig::default(), + disabled_header: None, + stream: Some(stream), + cache: None, + rate_limit: None, + geoip: None, + stream_history: None, + qos_aggregation: None, + hls_cache: None, + }), + user_access_control: true, + ..Config::default() + }; + + let mut app_cfg = create_test_app_config(); + app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); + create_test_app_state_for_config(Arc::new(app_cfg)) +} + +fn create_test_local_channel(url: &str) -> StreamChannel { + StreamChannel { + target_id: 1, + virtual_id: 41, + provider_id: 0, + input_name: "library".intern(), + item_type: PlaylistItemType::LocalVideo, + cluster: XtreamCluster::Video, + group: "Local Movies".intern(), + title: "Local Test".intern(), + url: url.into(), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + } +} + +fn create_test_live_channel(url: &str) -> StreamChannel { + StreamChannel { + target_id: 1, + virtual_id: 42, + provider_id: 1, + input_name: "provider_1".intern(), + item_type: PlaylistItemType::Live, + cluster: XtreamCluster::Live, + group: "Live".intern(), + title: "Shared Live".intern(), + url: url.into(), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + } +} + +fn create_test_session( + token: &str, + item_type: PlaylistItemType, + lifecycle: crate::api::model::PlaybackLifecycle, +) -> UserSession { + UserSession { + token: token.to_string(), + transition_version: 1, + virtual_id: 42, + provider: Arc::::from("provider-a"), + stream_url: Arc::::from(match item_type { + 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(), + ts: 1, + started_at: 1, + permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + lifecycle, + } +} + +#[test] +fn classify_playback_request_marks_adaptive_playlist_request_as_prepare() { + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: None, + prepare_only: true, + terminate: false, + }); + + assert_eq!(request_class, PlaybackRequestClass::Prepare); +} + +#[test] +fn classify_playback_request_marks_preserved_session_as_activate() { + let session = create_test_session( + "tok-preserved", + PlaylistItemType::LiveHls, + crate::api::model::PlaybackLifecycle::Preserved, + ); + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!(request_class, PlaybackRequestClass::Activate); +} + +#[test] +fn classify_playback_request_marks_counted_session_as_follow_up() { + let session = + create_test_session("tok-active", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Active); + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!(request_class, PlaybackRequestClass::FollowUp); +} + +/// `PendingProvider` must NOT be classified as `FollowUp`. +/// `PendingProvider` has no counted lease yet — the session is still waiting +/// for a provider slot. A new request on a `PendingProvider` session should +/// be `Activate` so that full admission evaluation happens, not a cheap +/// `FollowUp` skip. +#[test] +fn classify_playback_request_marks_pending_provider_as_activate_not_follow_up() { + let session = create_test_session( + "tok-pending", + PlaylistItemType::LiveHls, + crate::api::model::PlaybackLifecycle::PendingProvider { + data: crate::api::model::PendingProviderState { + reason_code: crate::api::model::PendingProviderReason::GraceHold, + created_at: 1, + deadline: 30, + version: 1, + wake_source: None, + }, + }, + ); + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!( + request_class, + PlaybackRequestClass::Activate, + "PendingProvider should not be FollowUp - it has no counted lease yet" + ); +} + +/// `Active` without a counted lease must NOT be classified as `FollowUp`. +/// `FollowUp` should only be returned when the session actually owns a +/// counted admission lease. A session with `Active` lifecycle but no counted +/// lease should go through `Activate` so that the counted lease is reacquired. +#[test] +fn classify_playback_request_marks_active_without_counted_as_activate_not_follow_up() { + let mut session = create_test_session( + "tok-active-uncounted", + PlaylistItemType::LiveHls, + crate::api::model::PlaybackLifecycle::Active, // counted=false via is_counted() + ); + // Manually force counted=false by setting to Prepared lifecycle, then restoring + // Note: is_counted() returns false for Prepared, true for Active + // For this test we need a session that is Active lifecycle but not counted + // The new model derives counted from lifecycle, so we must use a different lifecycle + // to represent "not counted". Use Prepared instead. + session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!( + request_class, + PlaybackRequestClass::Activate, + "Active session with counted=false should not be FollowUp" + ); +} + +/// Prepared sessions must be classified as Activate. +#[test] +fn classify_playback_request_marks_prepared_session_as_activate() { + let session = + create_test_session("tok-prepared", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Prepared); + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!(request_class, PlaybackRequestClass::Activate); +} + +/// `GraceActive` without counted lease must NOT be classified as `FollowUp`. +#[test] +fn classify_playback_request_marks_grace_active_without_counted_as_activate() { + let mut session = create_test_session( + "tok-grace-uncounted", + PlaylistItemType::LiveHls, + crate::api::model::PlaybackLifecycle::Active, // is_counted() = true for GraceActive + ); + // Test scenario: session has GraceActive lifecycle but we need it NOT counted + // This represents the edge case before grace task resolves. Use Prepared lifecycle + // to model "not counted" since is_counted() returns false for Prepared. + session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; + + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: Some(&session), + prepare_only: false, + terminate: false, + }); + + assert_eq!( + request_class, + PlaybackRequestClass::Activate, + "GraceActive session with counted=false should not be FollowUp" + ); +} + +#[tokio::test] +async fn activate_session_before_stream_open_skips_placeholder_for_follow_up_session() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), + }); + let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "follow-up-user".to_string(); + user.max_connections = 1; + let mut channel = create_test_live_channel("http://provider-1.example/live/55220.m3u8"); + channel.item_type = PlaylistItemType::LiveHls; + channel.virtual_id = 55220; + + app_state.connection_manager.add_connection(&addr).await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-follow-up", + virtual_id: channel.virtual_id, + provider: input.name.as_ref(), + stream_url: channel.url.as_ref(), + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 10, + fingerprint: &fingerprint, + provider: input.name.clone(), + stream_channel: &channel, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-follow-up"), + }) + .await; + + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-follow-up", + request_class: None, + virtual_id: VirtualId::new(channel.virtual_id), + item_type: PlaylistItemType::LiveHls, + stream_url: channel.url.as_ref(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: true, + }, + ) + .await; + + assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal)); + assert_eq!(activation.grace_mode, None); + assert!( + activation.placeholder_transition_version.is_none(), + "follow-up activation must not create a placeholder session" + ); +} + +#[tokio::test] +async fn activate_session_before_stream_open_revalidates_precomputed_follow_up_request_class() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), + }); + let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "precomputed-follow-up-user".to_string(); + user.max_connections = 1; + let mut channel = create_test_live_channel("http://provider-1.example/live/55221.m3u8"); + channel.item_type = PlaylistItemType::LiveHls; + channel.virtual_id = 55221; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-precomputed-follow-up", + virtual_id: channel.virtual_id, + provider: input.name.as_ref(), + stream_url: channel.url.as_ref(), + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-precomputed-follow-up", + request_class: Some(PlaybackRequestClass::FollowUp), + virtual_id: VirtualId::new(channel.virtual_id), + item_type: PlaylistItemType::LiveHls, + stream_url: channel.url.as_ref(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: true, + }, + ) + .await; + + assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal)); + assert_eq!(activation.grace_mode, None); + assert!( + activation.placeholder_transition_version.is_some(), + "precomputed FollowUp must be revalidated against the current uncounted lifecycle" + ); +} + +// stale FollowUp revalidation +#[tokio::test] +async fn activate_session_before_stream_open_stale_follow_up_reclassified_on_counted_lease_release() { + // Scenario: pre-computed FollowUp, but session's counted lease was released before + // the guard was acquired. Must reclassify to Activate so admission runs. + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), + }); + let addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "stale-followup-user".to_string(); + user.max_connections = 1; + let mut channel = create_test_live_channel("http://provider-1.example/live/55230.m3u8"); + channel.item_type = PlaylistItemType::LiveHls; + channel.virtual_id = 55230; + + // Session created in Active (counted) state. + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-stale-followup", + virtual_id: channel.virtual_id, + provider: input.name.as_ref(), + stream_url: channel.url.as_ref(), + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + // Simulate the counted lease being released before activation: + // expire the session so it no longer has a counted lease. + app_state.active_users.terminate_session(&user.username, "tok-stale-followup").await; + + // Call activate with stale FollowUp. Must NOT skip admission — reclassification + // to Activate must run so the placeholder is created. + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-stale-followup", + request_class: Some(PlaybackRequestClass::FollowUp), + virtual_id: VirtualId::new(channel.virtual_id), + item_type: PlaylistItemType::LiveHls, + stream_url: channel.url.as_ref(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: true, + }, + ) + .await; + + // Must NOT skip — placeholder must be created since session is expired. + assert!( + activation.placeholder_transition_version.is_some(), + "stale FollowUp with expired session must run admission and create placeholder" + ); +} + +// pre-resolved Grace materialization +#[tokio::test] +async fn activate_session_before_stream_open_pre_resolved_grace_period_materializes_pending_provider() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + let addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "pre-resolved-grace-user".to_string(); + user.max_connections = 1; + let mut channel = create_test_live_channel("http://provider-1.example/live/55231.m3u8"); + channel.item_type = PlaylistItemType::LiveHls; + channel.virtual_id = 55231; + + // Session in Prepared state (no grace lifecycle yet). + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-pre-resolved-grace", + virtual_id: channel.virtual_id, + provider: input.name.as_ref(), + stream_url: channel.url.as_ref(), + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + // Call activation with pre-resolved GracePeriod permission. + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-pre-resolved-grace", + request_class: None, + virtual_id: VirtualId::new(channel.virtual_id), + item_type: PlaylistItemType::LiveHls, + stream_url: channel.url.as_ref(), + connection_permission: UserConnectionPermission::GracePeriod, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: true, + }, + ) + .await; + + assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod); + assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold)); + + let session = app_state.active_users.get_and_update_user_session(&user.username, "tok-pre-resolved-grace").await; + assert!( + session.is_some_and(|s| matches!(s.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. })), + "pre-resolved GracePeriod must materialize as PendingProvider lifecycle" + ); +} + +/// `activate_session_before_stream_open` skips placeholder for Prepare class. +#[tokio::test] +async fn activate_session_before_stream_open_skips_placeholder_for_prepare() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), + }); + let addr: SocketAddr = "127.0.0.1:55222".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "prepare-user".to_string(); + user.max_connections = 1; + + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-prepare", + // Explicitly pass Prepare class — placeholder and admission should be skipped. + request_class: Some(PlaybackRequestClass::Prepare), + virtual_id: VirtualId::new(55222), + item_type: PlaylistItemType::LiveHls, + stream_url: "http://provider.example/live/test.ts", + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: true, + }, + ) + .await; + + // Prepare returns Allowed without running admission strategies. + assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(activation.grace_mode, None); + assert!( + activation.placeholder_transition_version.is_none(), + "Prepare activation must not create a placeholder session" + ); +} + +/// `resolve_playback_request_admission` with `prepare_only = true` returns `Prepare` class. +#[tokio::test] +async fn resolve_playback_request_admission_prepare_only_returns_prepare_class() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + let addr: SocketAddr = "127.0.0.1:55223".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let mut user = ProxyUserCredentials::default(); + user.username = "prepare-only-user".to_string(); + user.max_connections = 1; + + let (admission, grace_mode, request_class) = resolve_playback_request_admission( + &app_state.admission_ctx(), + &user, + &fingerprint, + None, + "tok-prepare-only", + false, + EvictionReentryGuard::Session("tok-prepare-only"), + true, // prepare_only + false, // terminate + ) + .await; + + assert_eq!(request_class, PlaybackRequestClass::Prepare); + // Prepare returns Allowed without running strategies. + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(grace_mode, None); +} + +/// `resolve_playback_request_admission` with `terminate = true` returns `Terminate` class +/// and calls `terminate_session` on the existing session. +#[tokio::test] +async fn resolve_playback_request_admission_terminate_returns_terminate_class() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + let addr: SocketAddr = "127.0.0.1:55224".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let mut user = ProxyUserCredentials::default(); + user.username = "terminate-user".to_string(); + user.max_connections = 2; + + // First create a session. + let session_token = "tok-terminate"; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token, + virtual_id: 55224, + provider: "test-provider", + stream_url: "http://provider.example/test.ts", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Verify session exists. + let before = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; + assert!(before.is_some(), "session should exist before terminate"); + + let (admission, grace_mode, request_class) = resolve_playback_request_admission( + &app_state.admission_ctx(), + &user, + &fingerprint, + before.as_ref(), + session_token, + false, + EvictionReentryGuard::Session(session_token), + false, // prepare_only + true, // terminate + ) + .await; + + assert_eq!(request_class, PlaybackRequestClass::Terminate); + assert_eq!(admission.permission, UserConnectionPermission::Exhausted); + assert_eq!(grace_mode, None); + + // Session should be expired after terminate. + let after = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; + assert!(after.is_none(), "session should be removed after terminate"); +} + +/// `classify_playback_request` returns `Terminate` when `terminate = true`. +#[test] +fn classify_playback_request_returns_terminate_when_flag_set() { + let request_class = classify_playback_request(PlaybackRequestFacts { + existing_session: None, + prepare_only: false, + terminate: true, + }); + assert_eq!(request_class, PlaybackRequestClass::Terminate); +} + +#[tokio::test] +async fn activate_session_before_stream_open_marks_pending_provider_for_grace_hold() { + let stream_cfg = crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }; + let mut app_cfg = create_test_app_config(); + app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { + user_access_control: true, + reverse_proxy: Some(crate::model::ReverseProxyConfig { + resource_rewrite_disabled: false, + rewrite_secret: [0; 16], + resource_retry: crate::model::ResourceRetryConfig::default(), + disabled_header: None, + stream: Some(stream_cfg), + cache: None, + rate_limit: None, + geoip: None, + stream_history: None, + qos_aggregation: None, + hls_cache: None, + }), + ..Config::default() + })); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); + let first_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let second_fingerprint = create_test_fingerprint(second_addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "grace-hold-user".to_string(); + user.max_connections = 1; + let first_channel = create_test_live_channel("http://provider-1.example/live/1.ts"); + let mut second_channel = create_test_live_channel("http://provider-1.example/live/2.m3u8"); + second_channel.item_type = PlaylistItemType::LiveHls; + second_channel.virtual_id = 55231; + + app_state.connection_manager.add_connection(&first_addr).await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 10, + fingerprint: &first_fingerprint, + provider: input.name.clone(), + stream_channel: &first_channel, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-first"), + }) + .await; + + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &second_fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-grace-hold", + request_class: None, + virtual_id: VirtualId::new(second_channel.virtual_id), + item_type: PlaylistItemType::LiveHls, + stream_url: second_channel.url.as_ref(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: false, + }, + ) + .await; + + assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod); + assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold)); + + let session = app_state + .active_users + .get_and_update_user_session(&user.username, "tok-grace-hold") + .await + .expect("placeholder session should exist"); + let crate::api::model::PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else { + panic!("grace hold should mark pending provider state") + }; + assert!(matches!(pending.reason_code, crate::api::model::PendingProviderReason::GraceHold)); + assert!(pending.deadline >= pending.created_at); + assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); + assert!( + !session.lifecycle.is_counted(), + "pending provider placeholder must not consume an active user lease before commit" + ); +} + +#[tokio::test] +async fn activate_session_before_stream_open_does_not_commit_user_lease_before_provider_success() { + let stream_cfg = crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 0, + grace_period_timeout_secs: 8, + grace_period_hold_stream: false, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: None, + }; + let mut app_cfg = create_test_app_config(); + app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { + user_access_control: true, + reverse_proxy: Some(crate::model::ReverseProxyConfig { + resource_rewrite_disabled: false, + rewrite_secret: [0; 16], + resource_retry: crate::model::ResourceRetryConfig::default(), + disabled_header: None, + stream: Some(stream_cfg), + cache: None, + rate_limit: None, + geoip: None, + stream_history: None, + qos_aggregation: None, + hls_cache: None, + }), + ..Config::default() + })); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); + let addr: SocketAddr = "127.0.0.1:55232".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input = app_state.app_config.sources.load().inputs[0].clone(); + let mut user = ProxyUserCredentials::default(); + user.username = "atomic-commit-user".to_string(); + user.max_connections = 1; + let channel = create_test_live_channel("http://provider-1.example/live/3.ts"); + + let activation = activate_session_before_stream_open( + &app_state, + SessionActivationRequest { + fingerprint: &fingerprint, + input: input.as_ref(), + user: &user, + session_token: "tok-atomic-commit", + request_class: None, + virtual_id: VirtualId::new(channel.virtual_id), + item_type: channel.item_type, + stream_url: channel.url.as_ref(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: crate::api::model::ConnectionKind::Normal, + socket_bound: false, + }, + ) + .await; + + assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(activation.grace_mode, None); + + let session = app_state + .active_users + .get_and_update_user_session(&user.username, "tok-atomic-commit") + .await + .expect("placeholder session should exist"); + assert_eq!( + app_state.active_users.user_connections(&user.username).await, + 0, + "allowed activation should stay provisional until provider acquisition and stream commit succeed" + ); + assert!( + !session.lifecycle.is_counted(), + "placeholder session must stay uncounted until the provider side has been committed" + ); + assert!(!matches!(session.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. })); +} + +fn create_test_shared_target() -> ConfigTarget { + ConfigTarget { + id: 1, + enabled: true, + name: "shared".to_string(), + options: Some(ConfigTargetOptions { + share_live_streams: shared::model::ConfigTargetShareLiveStreams { mpeg_ts: true, ..Default::default() }, + ..ConfigTargetOptions::default() + }), + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + } +} + +#[test] +fn admission_failure_reason_maps_to_custom_video_type() { + assert!(matches!( + admission_failure_video_type(ConnectFailureReason::UserAccountExpired), + Some(CustomVideoStreamType::UserAccountExpired) + )); + assert!(matches!( + admission_failure_video_type(ConnectFailureReason::UserConnectionsExhausted), + Some(CustomVideoStreamType::UserConnectionsExhausted) + )); + assert!(matches!( + admission_failure_video_type(ConnectFailureReason::ProviderConnectionsExhausted), + Some(CustomVideoStreamType::ProviderConnectionsExhausted) + )); + assert!(admission_failure_video_type(ConnectFailureReason::ProviderError).is_none()); +} + +#[tokio::test] +async fn effective_admission_strategies_use_legacy_grace_when_field_missing() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: None, + }); + + assert_eq!( + get_effective_admission_strategies(&app_state.admission_ctx()).as_ref(), + &[shared::model::AdmissionStrategy::GraceHoldStream][..] + ); +} + +#[tokio::test] +async fn effective_admission_strategies_respect_explicit_empty_list() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![]), + }); + + assert!(get_effective_admission_strategies(&app_state.admission_ctx()).is_empty()); +} + +#[tokio::test] +async fn grace_context_is_populated_when_grace_strategy_is_actually_granted() { + // Use a DIFFERENT session token than the pre-existing counted session. + // Otherwise session-admission may treat it as a valid reopen and skip the exhausted path. + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![ + AdmissionStrategy::EvictUserSameIpOldest, + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::EvictUserOldest, + ]), + }); + + let addr1: SocketAddr = "127.0.0.1:55401".parse().unwrap_or_else(|_| unreachable!()); + let addr2: SocketAddr = "10.0.0.5:55402".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint1 = create_test_fingerprint(addr1); + let fingerprint2 = create_test_fingerprint(addr2); + // addr1 and addr2 have DIFFERENT IPs. + // EvictUserSameIpOldest will NOT match (different IP), so GraceHoldStream is evaluated. + let mut user = ProxyUserCredentials::default(); + user.username = "user-grace-ctx".to_string(); + user.max_connections = 1; + + // Register the connection first so update_connection succeeds + app_state.connection_manager.add_connection(&addr1).await; + + // Create the session — lifecycle starts as Prepared (uncounted) + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-existing-counted", + virtual_id: 55401, + provider: "provider_1", + stream_url: "http://provider-1.example/live/55401.m3u8", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + // update_connection promotes the session to Active (counted) and creates a stream. + // This exhausts the user's single slot (max_connections = 1). + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 55401, + meter_uid: 55401, + username: "user-grace-ctx", + max_connections: 1, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint1, + provider: "provider_1".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/live/55401.m3u8"), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-existing-counted"), + }) + .await + .expect("stream should be created"); + + // Now the new request finds the slot exhausted and the grace strategy kicks in. + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new-request"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-request"), + }, + ) + .await; + + assert_eq!(result.admission.permission, UserConnectionPermission::GracePeriod, "grace should be granted"); + assert!(matches!(result.grace_mode, Some(crate::api::model::GraceMode::Hold))); + let ctx = result.grace_context.expect("grace_context must be present when grace is granted"); + assert_eq!(ctx.strategy_index, 1, "GraceHoldStream is at index 1"); + assert_eq!(ctx.strategies.len(), 3); + assert!(matches!(ctx.strategies[ctx.strategy_index], AdmissionStrategy::GraceHoldStream)); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_evicts_after_used_grace() { + // Strategies: [GraceHoldStream, EvictUserOldest] + // Grace was used at index 0, so only EvictUserOldest (index 1) is evaluated. + // Eviction frees the slot -> Allowed. + let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), + }); + + let addr1: SocketAddr = "127.0.0.1:55701".parse().unwrap_or_else(|_| unreachable!()); + let addr2: SocketAddr = "10.0.0.5:55702".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint1 = create_test_fingerprint(addr1); + let fingerprint2 = create_test_fingerprint(addr2); + + app_state.connection_manager.add_connection(&addr1).await; + app_state.connection_manager.add_connection(&addr2).await; + + let mut user = ProxyUserCredentials::default(); + user.username = "remaining-evict".to_string(); + user.max_connections = 1; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-counted", + virtual_id: 55701, + provider: "provider-evict", + stream_url: "http://provider.example/live/1.ts", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 55701, + meter_uid: 55701, + username: "remaining-evict", + max_connections: 1, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint1, + provider: "provider-evict".intern(), + stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-counted"), + }) + .await + .expect("stream should be created"); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "remaining-evict", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, + &grace_context, + Some(crate::api::model::ConnectionKind::Normal), + ) + .await; + + assert_eq!(result.admission.permission, UserConnectionPermission::Allowed, "EvictUserOldest should free the slot"); + assert!(result.grace_context.is_none(), "no grace context on eviction success"); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_skips_no_match_and_uses_later_eviction() { + // Strategies: [GraceHoldStream, EvictUserSameIpOldest, EvictUserOldest] + // Grace was at index 0, remaining are EvictUserSameIpOldest (index 1) and EvictUserOldest (index 2). + // The existing counted session is at a DIFFERENT IP, so EvictUserSameIpOldest -> NoMatch. + // EvictUserOldest succeeds -> Allowed. + let strategies = vec![ + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::EvictUserSameIpOldest, + AdmissionStrategy::EvictUserOldest, + ]; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![ + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::EvictUserSameIpOldest, + AdmissionStrategy::EvictUserOldest, + ]), + }); + + let addr1: SocketAddr = "127.0.0.1:55801".parse().unwrap_or_else(|_| unreachable!()); + let addr2: SocketAddr = "10.0.0.5:55802".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint1 = create_test_fingerprint(addr1); + let fingerprint2 = create_test_fingerprint(addr2); + + app_state.connection_manager.add_connection(&addr1).await; + app_state.connection_manager.add_connection(&addr2).await; + + let mut user = ProxyUserCredentials::default(); + user.username = "remaining-skip-no-match".to_string(); + user.max_connections = 1; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-counted", + virtual_id: 55801, + provider: "provider-skip", + stream_url: "http://provider.example/live/1.ts", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 55801, + meter_uid: 55801, + username: "remaining-skip-no-match", + max_connections: 1, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint1, + provider: "provider-skip".intern(), + stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-counted"), + }) + .await + .expect("stream should be created"); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "remaining-skip-no-match", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, + &grace_context, + Some(crate::api::model::ConnectionKind::Normal), + ) + .await; + + assert_eq!( + result.admission.permission, + UserConnectionPermission::Allowed, + "EvictUserSameIpOldest should NoMatch, EvictUserOldest should succeed" + ); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_empty_slice_denies() { + // Strategies: [GraceHoldStream] + // Grace was at index 0, remaining slice is empty -> exhausted. + let strategies = vec![AdmissionStrategy::GraceHoldStream]; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + + let addr: SocketAddr = "10.0.0.5:55901".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "no-remaining-strategies", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, + &grace_context, + None, + ) + .await; + + assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted, "empty remaining slice should deny"); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_preserves_soft_kind_on_exhausted() { + // Strategies: [GraceHoldStream] + // Grace was at index 0, remaining slice is empty -> exhausted. + // grace_context.kind is Soft — must be preserved in the exhausted result. + let strategies = vec![AdmissionStrategy::GraceHoldStream]; + let grace_context = GraceResolutionContext { + strategy_index: 0, + strategies: strategies.into(), + kind: Some(crate::api::model::ConnectionKind::Soft), + }; + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + + let addr: SocketAddr = "10.0.0.6:55902".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "soft-kind-user", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-soft"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-soft"), + }, + &grace_context, + Some(crate::api::model::ConnectionKind::Soft), + ) + .await; + + assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted, "empty remaining slice should deny"); + assert_eq!( + result.admission.kind, + Some(crate::api::model::ConnectionKind::Soft), + "exhausted result must preserve the original Soft connection kind" + ); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_does_not_retry_used_prefix() { + // Strategies: [GraceHoldStream, GraceInstantStream, EvictUserOldest] + // Grace was at index 1 (GraceInstantStream). + // Remaining slice: [EvictUserOldest] (index 2). + // GraceHoldStream (index 0) must NOT be re-evaluated. + let strategies = vec![ + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::GraceInstantStream, + AdmissionStrategy::EvictUserOldest, + ]; + let strategies_for_config = strategies.clone(); + let grace_context = GraceResolutionContext { strategy_index: 1, strategies: strategies.into(), kind: None }; + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(strategies_for_config), + }); + + let addr1: SocketAddr = "127.0.0.1:56001".parse().unwrap_or_else(|_| unreachable!()); + let addr2: SocketAddr = "10.0.0.5:56002".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint1 = create_test_fingerprint(addr1); + let fingerprint2 = create_test_fingerprint(addr2); + + app_state.connection_manager.add_connection(&addr1).await; + app_state.connection_manager.add_connection(&addr2).await; + + let mut user = ProxyUserCredentials::default(); + user.username = "remaining-no-retry".to_string(); + user.max_connections = 1; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-counted", + virtual_id: 56001, + provider: "provider-no-retry", + stream_url: "http://provider.example/live/1.ts", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 56001, + meter_uid: 56001, + username: "remaining-no-retry", + max_connections: 1, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint1, + provider: "provider-no-retry".intern(), + stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-counted"), + }) + .await + .expect("stream should be created"); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "remaining-no-retry", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, + &grace_context, + Some(crate::api::model::ConnectionKind::Normal), + ) + .await; + + assert_eq!( + result.admission.permission, + UserConnectionPermission::Allowed, + "only EvictUserOldest should be evaluated, not GraceHoldStream" + ); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_empty_slice_uses_original_kind_not_context_kind() { + // grace_context.kind = Normal, original_kind = Soft + // remaining slice is empty -> exhausted result must use original_kind. + // This proves the empty-slice branch uses original_kind, not grace_context.kind. + let strategies = vec![AdmissionStrategy::GraceHoldStream]; + let grace_context = GraceResolutionContext { + strategy_index: 0, + strategies: strategies.into(), + kind: Some(crate::api::model::ConnectionKind::Normal), + }; + let original_kind = Some(crate::api::model::ConnectionKind::Soft); + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), + }); + + let addr: SocketAddr = "10.0.0.7:55903".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "kind-mismatch-empty", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-empty"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-empty"), + }, + &grace_context, + original_kind, + ) + .await; + + assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted); + assert_eq!( + result.admission.kind, original_kind, + "exhausted result must use original_kind (Soft), not grace_context.kind (Normal)" + ); +} + +#[tokio::test] +async fn evaluate_remaining_strategies_later_grace_uses_original_kind_not_context_kind() { + // grace_context.kind = Normal, original_kind = Soft + // Strategies: [GraceHoldStream, GraceInstantStream] + // Grace was used at index 0 (GraceHoldStream). + // Remaining slice contains GraceInstantStream (index 1). + // When the helper returns Grace for the remaining strategy, the new + // GraceResolutionContext.kind must be original_kind (Soft), not grace_context.kind (Normal). + // This proves build_grace_ctx uses original_kind as source of truth. + let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]; + let grace_context = GraceResolutionContext { + strategy_index: 0, + strategies: strategies.into(), + kind: Some(crate::api::model::ConnectionKind::Normal), + }; + let original_kind = Some(crate::api::model::ConnectionKind::Soft); + + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]), + }); + + let addr1: SocketAddr = "127.0.0.1:55710".parse().unwrap_or_else(|_| unreachable!()); + let addr2: SocketAddr = "10.0.0.8:55711".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint1 = create_test_fingerprint(addr1); + let fingerprint2 = create_test_fingerprint(addr2); + + app_state.connection_manager.add_connection(&addr1).await; + app_state.connection_manager.add_connection(&addr2).await; + + let mut user = ProxyUserCredentials::default(); + user.username = "kind-mismatch-grace".to_string(); + user.max_connections = 1; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-counted-grace", + virtual_id: 55710, + provider: "provider-grace-kind", + stream_url: "http://provider.example/live/1.ts", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 55710, + meter_uid: 55710, + username: "kind-mismatch-grace", + max_connections: 1, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint1, + provider: "provider-grace-kind".intern(), + stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-counted-grace"), + }) + .await + .expect("stream should be created"); + + let result = evaluate_remaining_strategies_after_grace( + &app_state.admission_ctx(), + AdmissionRequest { + username: "kind-mismatch-grace", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new-grace"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-grace"), + }, + &grace_context, + original_kind, + ) + .await; + + assert_eq!( + result.admission.permission, + UserConnectionPermission::GracePeriod, + "remaining GraceInstantStream should grant GracePeriod" + ); + assert!(result.grace_context.is_some(), "grace_context must be present when grace is granted"); + assert_eq!( + result.grace_context.as_ref().unwrap().kind, + original_kind, + "GraceResolutionContext.kind in the result must be original_kind (Soft), not grace_context.kind (Normal)" + ); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn resolve_admission_with_strategies_falls_through_after_failed_grace_grant() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), + }); + + let first_addr: std::net::SocketAddr = "127.0.0.1:55151".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: std::net::SocketAddr = "127.0.0.1:55152".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let second_fingerprint = create_test_fingerprint(second_addr); + + app_state.connection_manager.add_connection(&first_addr).await; + app_state.connection_manager.add_connection(&second_addr).await; + + let mut session_user = ProxyUserCredentials::default(); + session_user.username = "fallthrough".to_string(); + session_user.max_connections = 1; + session_user.soft_connections = 1; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "tok-first", + virtual_id: 1, + provider: "provider-a", + stream_url: "http://provider-1.example/live/1.ts", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: "fallthrough", + max_connections: 1, + soft_connections: 1, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 10, + fingerprint: &first_fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/live/1.ts"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some("tok-first"), + }) + .await; + + assert!(app_state.active_users.grant_grace("fallthrough").await); + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "tok-second", + virtual_id: 2, + provider: "provider-a", + stream_url: "http://provider-1.example/live/2.ts", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Soft), + socket_bound: false, + }) + .await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: "fallthrough", + max_connections: 1, + soft_connections: 1, + connection_kind: crate::api::model::ConnectionKind::Soft, + priority: 0, + soft_priority: 10, + fingerprint: &second_fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/live/2.ts"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some("tok-second"), + }) + .await; + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: "fallthrough", + max_connections: 1, + soft_connections: 1, + client_ip: "127.0.0.1", + request_addr: &"127.0.0.1:55153".parse().unwrap_or_else(|_| unreachable!()), + use_session_admission: true, + session_token: Some("tok-third"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-third"), + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Normal)); + assert_eq!(grace_mode, None); +} + +#[tokio::test] +async fn resolve_admission_with_strategies_allows_existing_session_even_when_user_is_at_limit() { + let app_state = create_test_app_state(); + let addr = "127.0.0.1:55154".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let mut user = ProxyUserCredentials::default(); + user.username = "session-admission".to_string(); + user.max_connections = 1; + + app_state.connection_manager.add_connection(&addr).await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "vod-session", + virtual_id: 1, + provider: "provider-a", + stream_url: "http://provider-1.example/movie/1.mkv", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 10, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/movie/1.mkv"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some("vod-session"), + }) + .await; + + let session_based = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("vod-session"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), + }, + ) + .await; + assert_eq!(session_based.admission.permission, UserConnectionPermission::Allowed); + + let connection_based = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: false, + session_token: Some("vod-session"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), + }, + ) + .await; + assert_eq!(connection_based.admission.permission, UserConnectionPermission::Exhausted); +} + +#[tokio::test] +async fn resolve_admission_with_strategies_prevents_recently_evicted_playback_ping_pong() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 0, + grace_period_timeout_secs: 8, + grace_period_hold_stream: false, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), + }); + + let victim_addr: std::net::SocketAddr = "127.0.0.1:55181".parse().unwrap_or_else(|_| unreachable!()); + let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55182".parse().unwrap_or_else(|_| unreachable!()); + let winner_addr: std::net::SocketAddr = "127.0.0.1:55183".parse().unwrap_or_else(|_| unreachable!()); + let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); + let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0"); + let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); + let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9001.ts"); + victim_channel.virtual_id = 9001; + let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9002.ts"); + winner_channel.virtual_id = 9002; + + app_state.connection_manager.add_connection(&victim_addr).await; + app_state.connection_manager.add_connection(&winner_addr).await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: "loop-user", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &victim_fingerprint, + provider: "provider-a".intern(), + stream_channel: &victim_channel, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("session-victim"), + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: "loop-user", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &winner_fingerprint, + provider: "provider-a".intern(), + stream_channel: &winner_channel, + user_agent: std::borrow::Cow::Borrowed("winner/1.0"), + session_token: Some("session-winner"), + }) + .await; + + app_state + .active_users + .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) + .await; + app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: "loop-user", + max_connections: 1, + soft_connections: 0, + client_ip: &reconnect_fingerprint.client_ip, + request_addr: &reconnect_fingerprint.addr, + use_session_admission: true, + session_token: Some("socket-reconnect"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9001) }, + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + + assert_eq!(admission.permission, UserConnectionPermission::Exhausted); + assert_eq!(grace_mode, None); + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1); + assert_eq!(active_streams[0].channel.virtual_id, 9002); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn resolve_admission_with_strategies_allows_other_channel_after_recent_eviction() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 0, + grace_period_timeout_secs: 8, + grace_period_hold_stream: false, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), + }); + + let victim_addr: std::net::SocketAddr = "127.0.0.1:55184".parse().unwrap_or_else(|_| unreachable!()); + let winner_addr: std::net::SocketAddr = "127.0.0.1:55185".parse().unwrap_or_else(|_| unreachable!()); + let new_addr: std::net::SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!()); + let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); + let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); + let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0"); + let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9101.ts"); + victim_channel.virtual_id = 9101; + let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9102.ts"); + winner_channel.virtual_id = 9102; + let mut session_user = ProxyUserCredentials::default(); + session_user.username = "loop-user-2".to_string(); + + app_state.connection_manager.add_connection(&victim_addr).await; + app_state.connection_manager.add_connection(&winner_addr).await; + + // Create sessions before update_connection so streams are linked to counted sessions + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "session-victim", + virtual_id: 9101, + provider: "provider-a", + stream_url: "http://provider-1.example/live/9101.ts", + addr: &victim_addr, + connection_permission: shared::model::UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "session-winner", + virtual_id: 9102, + provider: "provider-a", + stream_url: "http://provider-1.example/live/9102.ts", + addr: &winner_addr, + connection_permission: shared::model::UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: "loop-user-2", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &victim_fingerprint, + provider: "provider-a".intern(), + stream_channel: &victim_channel, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("session-victim"), + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: "loop-user-2", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &winner_fingerprint, + provider: "provider-a".intern(), + stream_channel: &winner_channel, + user_agent: std::borrow::Cow::Borrowed("winner/1.0"), + session_token: Some("session-winner"), + }) + .await; + + app_state + .active_users + .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) + .await; + app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: "loop-user-2", + max_connections: 1, + soft_connections: 0, + client_ip: &new_fingerprint.client_ip, + request_addr: &new_fingerprint.addr, + use_session_admission: true, + session_token: Some("session-new"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9103) }, + }, + ) + .await; + let admission = result.admission; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn resolve_admission_with_strategies_does_not_suppress_different_session_on_same_channel() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 0, + grace_period_timeout_secs: 8, + grace_period_hold_stream: false, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), + }); + + let victim_addr: std::net::SocketAddr = "127.0.0.1:55190".parse().unwrap_or_else(|_| unreachable!()); + let winner_addr: std::net::SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!()); + let new_addr: std::net::SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!()); + let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); + let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "player/1.0"); + let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0"); + let mut channel = create_test_live_channel("http://provider-1.example/live/9301.m3u8"); + channel.virtual_id = 9301; + channel.item_type = PlaylistItemType::LiveHls; + let mut session_user = ProxyUserCredentials::default(); + session_user.username = "loop-user-4".to_string(); + + app_state.connection_manager.add_connection(&victim_addr).await; + app_state.connection_manager.add_connection(&winner_addr).await; + + // Create sessions before update_connection so streams are linked to counted sessions + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "session-victim", + virtual_id: 9301, + provider: "provider-a", + stream_url: "http://provider-1.example/live/9301.m3u8", + addr: &victim_addr, + connection_permission: shared::model::UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &session_user, + session_token: "session-winner", + virtual_id: 9301, + provider: "provider-a", + stream_url: "http://provider-1.example/live/9301.m3u8", + addr: &winner_addr, + connection_permission: shared::model::UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: "loop-user-4", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &victim_fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("session-victim"), + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: "loop-user-4", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &winner_fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("session-winner"), + }) + .await; + + app_state + .active_users + .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) + .await; + app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: "loop-user-4", + max_connections: 1, + soft_connections: 0, + client_ip: &new_fingerprint.client_ip, + request_addr: &new_fingerprint.addr, + use_session_admission: true, + session_token: Some("session-other"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("session-other"), + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(grace_mode, None); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn resolve_admission_with_strategies_allows_recently_evicted_playback_when_soft_slot_is_free() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 0, + grace_period_timeout_secs: 8, + grace_period_hold_stream: false, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), + }); + + let victim_addr: std::net::SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!()); + let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55188".parse().unwrap_or_else(|_| unreachable!()); + let winner_addr: std::net::SocketAddr = "127.0.0.1:55189".parse().unwrap_or_else(|_| unreachable!()); + let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0"); + let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0"); + let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0"); + let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9201.ts"); + victim_channel.virtual_id = 9201; + let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9202.ts"); + winner_channel.virtual_id = 9202; + + app_state.connection_manager.add_connection(&victim_addr).await; + app_state.connection_manager.add_connection(&winner_addr).await; + + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: "loop-user-3", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &victim_fingerprint, + provider: "provider-a".intern(), + stream_channel: &victim_channel, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("session-victim"), + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: "loop-user-3", + max_connections: 2, + soft_connections: 0, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &winner_fingerprint, + provider: "provider-a".intern(), + stream_channel: &winner_channel, + user_agent: std::borrow::Cow::Borrowed("winner/1.0"), + session_token: Some("session-winner"), + }) + .await; + + app_state + .active_users + .mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS) + .await; + app_state.connection_manager.release_connection_as_kicked(&victim_addr).await; + + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: "loop-user-3", + max_connections: 1, + soft_connections: 1, + client_ip: &reconnect_fingerprint.client_ip, + request_addr: &reconnect_fingerprint.addr, + use_session_admission: true, + session_token: Some("socket-reconnect"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9201) }, + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft)); + assert_eq!(grace_mode, None); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1); + assert_eq!(active_streams[0].channel.virtual_id, 9202); +} + +#[tokio::test] +async fn local_stream_response_registers_active_local_stream() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let addr = "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + + let _response = local_stream_response( + &fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + None, + None, + false, + ) + .await + .into_response(); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1, "local file streaming should register an active stream"); + assert_eq!(active_streams[0].channel.item_type, PlaylistItemType::LocalVideo); +} + +#[tokio::test] +async fn local_stream_response_rechecks_limits_before_registering_socket_bound_streams() { + let mut app_cfg = create_test_app_config(); + let config = Config { user_access_control: true, ..Config::default() }; + app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-race-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let first_addr = "127.0.0.1:55131".parse().unwrap_or_else(|_| unreachable!()); + let second_addr = "127.0.0.1:55132".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let second_fingerprint = create_test_fingerprint(second_addr); + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let mut user = ProxyUserCredentials::default(); + user.username = "local-limit-user".to_string(); + user.max_connections = 1; + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + let first_token = create_session_fingerprint(&first_fingerprint, &user.username, channel.virtual_id, true); + let second_token = create_session_fingerprint(&second_fingerprint, &user.username, channel.virtual_id, true); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + Some(&first_token), + None, + false, + ) + .await + .into_response(); + + let _second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + Some(&second_token), + None, + false, + ) + .await + .into_response(); + + assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); + assert_eq!(app_state.active_users.active_streams().await.len(), 1); + assert_eq!( + app_state + .active_users + .connection_admission_for_session( + &user.username, + user.max_connections, + user.soft_connections, + &second_token + ) + .await + .permission, + UserConnectionPermission::Exhausted, + "failed second open must not leave a placeholder session that bypasses admission" + ); +} + +#[tokio::test] +async fn stream_response_preserves_soft_kind_for_shared_reuse() { + let app_state = create_test_provider_app_state(); + let stream_url = "http://provider-1.example/live/shared.ts"; + let input_name = "provider_1".intern(); + let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist"); + let target = Arc::new(create_test_shared_target()); + + let owner_addr = "127.0.0.1:55140".parse().unwrap_or_else(|_| unreachable!()); + let owner_handle = app_state + .active_provider + .acquire_connection(&input.name, &owner_addr, 0, crate::api::model::ConnectionKind::Normal) + .await + .expect("owner allocation should exist"); + let shared_stream = stream::pending::>(); + let registered = SharedStreamManager::register_shared_stream( + SharedStreamCtx { + app_config: &app_state.app_config, + shared_stream_manager: &app_state.shared_stream_manager, + active_provider: &app_state.active_provider, + connection_manager: &app_state.connection_manager, + }, + stream_url, + shared_stream, + &owner_addr, + Vec::new(), + 1, + Some(owner_handle), + 0, + crate::api::model::ConnectionKind::Normal, + ) + .await; + assert!(registered.is_some(), "shared stream should register"); + + let mut user = ProxyUserCredentials::default(); + user.username = "soft-user".to_string(); + user.max_connections = 1; + user.soft_connections = 1; + user.priority = 0; + user.soft_priority = 9; + + let normal_addr = "127.0.0.1:55141".parse().unwrap_or_else(|_| unreachable!()); + let normal_fingerprint = create_test_fingerprint(normal_addr); + let normal_channel = create_test_live_channel("http://provider-1.example/live/normal.ts"); + app_state.active_users.add_connection(&normal_addr).await; + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 1001, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &normal_fingerprint, + provider: input.name.clone(), + stream_channel: &normal_channel, + user_agent: Cow::Borrowed("ua"), + session_token: Some("normal-session"), + }) + .await + .expect("normal stream should register"); + + let admission = + app_state.active_users.connection_admission(&user.username, user.max_connections, user.soft_connections).await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft)); + + let soft_addr = "127.0.0.1:55142".parse().unwrap_or_else(|_| unreachable!()); + let soft_fingerprint = create_test_fingerprint(soft_addr); + let response = stream_response( + &soft_fingerprint, + &app_state, + "soft-session", + None, + create_test_live_channel(stream_url), + stream_url, + None, + &HeaderMap::default(), + &input, + &target, + &user, + admission.permission, + admission.kind.unwrap_or(crate::api::model::ConnectionKind::Normal), + false, + None, + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + + let session_admission = app_state + .active_users + .connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, "soft-session") + .await; + assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); +} + +#[tokio::test] +async fn stream_response_rolls_back_provisional_user_activation_when_provider_open_fails() { + let mut app_cfg = create_test_provider_app_config(); + app_cfg.config = Arc::new(ArcSwap::from_pointee(Config { + user_access_control: true, + custom_stream_response_enabled: true, + ..Config::default() + })); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); + let addr = "127.0.0.1:55143".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let input_name = "provider_1".intern(); + let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist"); + let target = Arc::new(ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }); + let mut user = ProxyUserCredentials::default(); + user.username = "rollback-user".to_string(); + user.max_connections = 1; + let stream_url = "provider://bad-url"; + let channel = create_test_live_channel(stream_url); + + let response = stream_response( + &fingerprint, + &app_state, + "rollback-session", + None, + channel, + stream_url, + None, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + false, + None, + ) + .await + .into_response(); + + // Custom-video stream is enabled (`custom_stream_response_enabled: true` + // in this fixture), so a missing resource must return 400 — the + // Nginx `proxy_intercept_errors on;` contract requires 4xx so the + // socket is severed instead of looping on a 200 OK fallback body. + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + app_state.active_users.user_connections(&user.username).await, + 0, + "failed provider open must rollback provisional user activation" + ); + assert!( + app_state.active_users.get_and_update_user_session(&user.username, "rollback-session").await.is_none(), + "failed provider open must remove the provisional placeholder session" + ); +} + +/// 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, + provider_session_headers: HashMap::new(), + grace_period: GracePeriodOptions::default(), + provider_grace_active: false, + disable_provider_grace: false, + reconnect_flag: None, + provider_handle: None, + content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, + 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, + provider_session_headers: HashMap::new(), + grace_period: GracePeriodOptions::default(), + provider_grace_active: false, + disable_provider_grace: false, + reconnect_flag: None, + provider_handle: None, + content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, + 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, + provider_session_headers: HashMap::new(), + grace_period: GracePeriodOptions::default(), + provider_grace_active: false, + disable_provider_grace: false, + reconnect_flag: None, + provider_handle: None, + content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, + 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(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let addr = "127.0.0.1:55124".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = create_test_fingerprint(addr); + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + + let response = local_stream_response( + &fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + None, + None, + false, + ) + .await + .into_response(); + + assert!(!should_compress_response(&response)); +} + +#[tokio::test] +async fn local_stream_response_reuses_stable_playback_session_token_across_reopens() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let user = ProxyUserCredentials::default(); + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + let playback_session_token = "local-playback-token"; + + let first_fingerprint = create_test_fingerprint("127.0.0.1:55125".parse().unwrap_or_else(|_| unreachable!())); + let second_fingerprint = create_test_fingerprint("127.0.0.1:55126".parse().unwrap_or_else(|_| unreachable!())); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + let _second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1, "stable playback token should reuse the tracked local connection"); + assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); + assert_eq!(active_streams[0].addr, second_fingerprint.addr); +} + +#[tokio::test] +async fn local_stream_response_allows_exhausted_reopen_for_same_playback_session_token() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let mut user = ProxyUserCredentials::default(); + user.username = "user1".to_string(); + user.max_connections = 1; + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + let playback_session_token = "local-playback-token"; + + let first_fingerprint = create_test_fingerprint("127.0.0.1:55127".parse().unwrap_or_else(|_| unreachable!())); + let second_fingerprint = create_test_fingerprint("127.0.0.1:55128".parse().unwrap_or_else(|_| unreachable!())); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Normal, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + let second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Exhausted, + crate::api::model::ConnectionKind::Normal, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + assert_eq!(second_response.status(), StatusCode::OK); + + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1); + assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token)); + assert_eq!(active_streams[0].addr, second_fingerprint.addr); +} + +#[tokio::test] +async fn local_stream_response_preserves_soft_kind_across_reopens() { + let app_state = create_test_app_state(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("local-soft-test.mkv"); + tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file"); + + let channel = create_test_local_channel(&format!("file://{}", file_path.display())); + let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() }; + let mut user = ProxyUserCredentials::default(); + user.username = "soft-local-user".to_string(); + user.max_connections = 1; + user.soft_connections = 1; + let target = ConfigTarget { + id: 1, + enabled: true, + name: "test".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + output: Vec::new(), + rename: None, + mapping_ids: None, + mapping: Arc::new(ArcSwapOption::default()), + favourites: None, + processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: false, + }; + let playback_session_token = "local-soft-playback-token"; + + let first_fingerprint = create_test_fingerprint("127.0.0.1:55129".parse().unwrap_or_else(|_| unreachable!())); + let second_fingerprint = create_test_fingerprint("127.0.0.1:55130".parse().unwrap_or_else(|_| unreachable!())); + + let _first_response = local_stream_response( + &first_fingerprint, + &app_state, + channel.clone(), + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Allowed, + crate::api::model::ConnectionKind::Soft, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + let second_response = local_stream_response( + &second_fingerprint, + &app_state, + channel, + &HeaderMap::default(), + &input, + &target, + &user, + UserConnectionPermission::Exhausted, + crate::api::model::ConnectionKind::Normal, + Some(playback_session_token), + None, + false, + ) + .await + .into_response(); + + assert_eq!(second_response.status(), StatusCode::OK); + + let session_admission = app_state + .active_users + .connection_admission_for_session( + &user.username, + user.max_connections, + user.soft_connections, + playback_session_token, + ) + .await; + assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); +} + +#[tokio::test] +async fn activated_session_admission_keeps_hls_placeholders_uncounted_via_api_utils() { + let app_state = create_test_app_state(); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.max_connections = 1; + + let first_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: std::net::SocketAddr = "127.0.0.1:55178".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let second_fingerprint = create_test_fingerprint(second_addr); + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-hls-first", + virtual_id: 7101, + provider: "provider-a", + stream_url: "http://provider-1.example/live/7101.m3u8", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-hls-second", + virtual_id: 7102, + provider: "provider-a", + stream_url: "http://provider-1.example/live/7102.m3u8", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let first_admission = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &first_fingerprint.client_ip, + request_addr: &first_fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-hls-first"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-first"), + }, + ) + .await; + let second_admission = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &second_fingerprint.client_ip, + request_addr: &second_fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-hls-second"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-second"), + }, + ) + .await; + + assert_eq!(first_admission.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(second_admission.admission.permission, UserConnectionPermission::Allowed); + assert_eq!(app_state.active_users.user_connections(&user.username).await, 0); +} + +#[tokio::test] +async fn socket_bound_playback_sessions_enforce_hard_limits_per_socket() { + let app_state = create_test_app_state(); + let mut user = ProxyUserCredentials::default(); + user.username = "user1".to_string(); + user.max_connections = 1; + + let first_addr: std::net::SocketAddr = "127.0.0.1:55171".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: std::net::SocketAddr = "127.0.0.1:55172".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 5001, true); + let second_fingerprint = create_test_fingerprint(second_addr); + let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 5001, true); + + app_state.connection_manager.add_connection(&first_addr).await; + app_state.connection_manager.add_connection(&second_addr).await; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: &first_token, + virtual_id: 5001, + provider: "provider-a", + stream_url: "http://provider-1.example/vod/5001.ts", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 5001, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &first_fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/vod/5001.ts"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some(&first_token), + }) + .await; + + let admission = app_state + .active_users + .connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &second_token) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Exhausted); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn resolve_admission_with_strategies_evicts_preserved_hls_session_for_same_user_ts_request() { + let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![ + AdmissionStrategy::EvictUserSameIpOldest, + AdmissionStrategy::EvictUserSameIpLatest, + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::EvictUserOldest, + AdmissionStrategy::EvictUserLatest, + ]), + }); + + let hls_addr: std::net::SocketAddr = "127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!()); + let ts_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!()); + let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "player/1.0"); + let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "player/1.0"); + let mut user = ProxyUserCredentials::default(); + user.username = "same-user".to_string(); + user.max_connections = 1; + + app_state.connection_manager.add_connection(&hls_addr).await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: "tok-hls-preserved", + virtual_id: 5001, + provider: "provider-a", + stream_url: "http://provider-1.example/live/5001.m3u8", + addr: &hls_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &hls_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { + item_type: PlaylistItemType::LiveHls, + virtual_id: 5001, + ..create_test_live_channel("http://provider-1.example/live/5001.m3u8") + }, + user_agent: std::borrow::Cow::Borrowed("player/1.0"), + session_token: Some("tok-hls-preserved"), + }) + .await; + + app_state.connection_manager.release_connection(&hls_addr).await; + assert_eq!(app_state.active_users.user_connections(&user.username).await, 0); + assert!(app_state.active_users.active_streams().await.is_empty()); + + let mut close_rx = app_state.connection_manager.get_close_connection_channel(); + let result = resolve_admission_with_strategies( + &app_state.admission_ctx(), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &ts_fingerprint.client_ip, + request_addr: &ts_fingerprint.addr, + use_session_admission: false, + session_token: None, + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(5001) }, + }, + ) + .await; + let admission = result.admission; + let grace_mode = result.grace_mode; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(grace_mode, None); + assert!(app_state.active_users.active_streams().await.is_empty()); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_millis(100), close_rx.recv()).await.ok().and_then(Result::ok), + Some(crate::api::model::CloseConnectionSignal::WithReason( + hls_addr, + shared::model::DisconnectReason::ClientKicked, + )) + ); + assert!( + app_state.active_users.get_and_update_user_session(&user.username, "tok-hls-preserved").await.is_none(), + "preserved session should be removed once the TS request evicts it" + ); +} + +#[tokio::test] +async fn socket_bound_playback_sessions_still_allow_soft_slots() { + let app_state = create_test_app_state(); + let mut user = ProxyUserCredentials::default(); + user.username = "soft-user".to_string(); + user.max_connections = 1; + user.soft_connections = 1; + user.priority = 0; + user.soft_priority = 9; + + let first_addr: std::net::SocketAddr = "127.0.0.1:55173".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: std::net::SocketAddr = "127.0.0.1:55174".parse().unwrap_or_else(|_| unreachable!()); + let third_addr: std::net::SocketAddr = "127.0.0.1:55175".parse().unwrap_or_else(|_| unreachable!()); + let first_fingerprint = create_test_fingerprint(first_addr); + let second_fingerprint = create_test_fingerprint(second_addr); + let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 6001, true); + let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 6001, true); + let third_fingerprint = create_test_fingerprint(third_addr); + let third_token = create_session_fingerprint(&third_fingerprint, &user.username, 6001, true); + + app_state.connection_manager.add_connection(&first_addr).await; + app_state.connection_manager.add_connection(&second_addr).await; + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: &first_token, + virtual_id: 6001, + provider: "provider-a", + stream_url: "http://provider-1.example/vod/6001.ts", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: true, + }) + .await; + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 6001, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &first_fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/vod/6001.ts"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some(&first_token), + }) + .await; + + let second_admission = app_state + .active_users + .connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &second_token) + .await; + assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(second_admission.kind, Some(crate::api::model::ConnectionKind::Soft)); + + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: &second_token, + virtual_id: 6001, + provider: "provider-a", + stream_url: "http://provider-1.example/vod/6001.ts", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Soft), + socket_bound: true, + }) + .await; + app_state + .active_users + .update_connection(crate::api::model::ActiveUserConnectionParams { + uid: 6002, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Soft, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &second_fingerprint, + provider: "provider-a".intern(), + stream_channel: &create_test_live_channel("http://provider-1.example/vod/6002.ts"), + user_agent: std::borrow::Cow::Borrowed("ua"), + session_token: Some(&second_token), + }) + .await; + + let third_admission = app_state + .active_users + .connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &third_token) + .await; + assert_eq!(third_admission.permission, UserConnectionPermission::Exhausted); +} + +#[test] +fn session_based_playback_matches_adaptive_types_and_extensions() { + assert!(is_session_based_playback(PlaylistItemType::LiveHls, None)); + assert!(is_session_based_playback(PlaylistItemType::LiveDash, None)); + assert!(is_session_based_playback(PlaylistItemType::Live, Some(HLS_EXT))); + assert!(is_session_based_playback(PlaylistItemType::Live, Some(DASH_EXT))); + assert!(!is_session_based_playback(PlaylistItemType::Video, None)); +} + +#[test] +fn create_session_fingerprint_switches_between_logical_and_socket_bound_keys() { + let fingerprint = create_test_fingerprint("127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!())); + let logical = create_session_fingerprint(&fingerprint, "user1", 7001, false); + let socket_bound = create_session_fingerprint(&fingerprint, "user1", 7001, true); + + assert_ne!(logical, socket_bound); + assert!(logical.contains(&fingerprint.key)); + assert!(socket_bound.contains(&fingerprint.addr.to_string())); +} + +#[test] +fn adaptive_playback_session_fingerprint_is_logical_across_initial_sockets() { + let Some(first_addr) = "127.0.0.1:55177".parse().ok() else { + return; + }; + let Some(second_addr) = "127.0.0.1:55178".parse().ok() else { + return; + }; + let first = Fingerprint::new("10.0.0.6|player".to_string(), "10.0.0.6".to_string(), first_addr); + let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr); + + 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_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] +fn playback_session_fingerprint_keeps_ts_socket_bound_but_vod_logical() { + let first_addr: SocketAddr = "127.0.0.1:55179".parse().unwrap_or_else(|_| unreachable!()); + let second_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap_or_else(|_| unreachable!()); + let first = Fingerprint::new("10.0.0.7|player".to_string(), "10.0.0.7".to_string(), first_addr); + let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr); + + let first_ts = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Live, None); + let second_ts = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Live, None); + let first_vod = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Video, None); + let second_vod = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Video, None); + + assert_ne!(first_ts, second_ts, "plain TS live remains socket-bound"); + assert_eq!(first_vod, second_vod, "VOD remains logical across reopen/seek sockets"); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn xtream_hls_then_ts_uses_distinct_tokens_and_evicts_old_hls_session() { + let mut app_cfg = create_test_app_config(); + let config = Config { + user_access_control: true, + reverse_proxy: Some(crate::model::ReverseProxyConfig { + resource_rewrite_disabled: false, + rewrite_secret: [0; 16], + resource_retry: crate::model::ResourceRetryConfig::default(), + disabled_header: None, + stream: Some(crate::model::StreamConfig { + retry: true, + metrics_enabled: true, + buffer: None, + grace_period_millis: 2_000, + grace_period_timeout_secs: 8, + grace_period_hold_stream: true, + hls_session_ttl_secs: 10, + catchup_session_ttl_secs: 10, + throttle_str: None, + throttle_kbps: 0, + shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, + admission_strategies: Some(vec![ + AdmissionStrategy::EvictUserSameIpOldest, + AdmissionStrategy::EvictUserSameIpLatest, + AdmissionStrategy::GraceHoldStream, + AdmissionStrategy::EvictUserOldest, + AdmissionStrategy::EvictUserLatest, + ]), + }), + cache: None, + rate_limit: None, + geoip: None, + stream_history: None, + qos_aggregation: None, + hls_cache: None, + }), + ..Config::default() + }; + app_cfg.config = Arc::new(ArcSwap::from_pointee(config)); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg)); + let hls_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!()); + let ts_addr: SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!()); + let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "libmpv"); + let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "libmpv"); + let mut user = ProxyUserCredentials::default(); + user.username = "xtream-hls-ts".to_string(); + user.max_connections = 1; + + let virtual_id = 7811; + let hls_token = create_session_fingerprint(&hls_fingerprint, &user.username, virtual_id, false); + let ts_token = create_session_fingerprint(&ts_fingerprint, &user.username, virtual_id, true); + assert_ne!(hls_token, ts_token, "Xtream .m3u8 and .ts must not share the same playback token"); + + let mut hls_channel = create_test_live_channel("http://provider-1.example/live/7811.m3u8"); + hls_channel.virtual_id = virtual_id; + hls_channel.item_type = PlaylistItemType::LiveHls; + let mut ts_channel = create_test_live_channel("http://provider-1.example/live/7811.ts"); + ts_channel.virtual_id = virtual_id; + + app_state.connection_manager.add_connection(&hls_addr).await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: &hls_token, + virtual_id, + provider: "provider_1", + stream_url: hls_channel.url.as_ref(), + addr: &hls_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + socket_bound: false, + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 1, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &hls_fingerprint, + provider: "provider_1".intern(), + stream_channel: &hls_channel, + user_agent: Cow::Borrowed("libmpv"), + session_token: Some(&hls_token), + }) + .await; + + app_state.connection_manager.release_connection(&hls_addr).await; + assert_eq!( + app_state.active_users.user_connections(&user.username).await, + 0, + "the preserved HLS playback must reserve capacity only virtually" + ); + assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0)); + assert!( + app_state.active_users.get_and_update_user_session(&user.username, &hls_token).await.is_some(), + "preserved HLS session should still exist before the competing TS request" + ); + assert_eq!( + app_state + .active_users + .connection_admission(&user.username, user.max_connections, user.soft_connections) + .await + .permission, + UserConnectionPermission::Exhausted, + "the preserved HLS playback must still reserve the user's only slot before the TS request is evaluated" + ); + assert_eq!( + app_state.active_users.get_eviction_candidates(&user.username, &ts_fingerprint.client_ip).await.len(), + 1, + "the preserved HLS playback should be the single eviction candidate for the competing TS request" + ); + + let (ts_admission, ts_grace_mode, request_class) = resolve_playback_request_admission( + &app_state.admission_ctx(), + &user, + &ts_fingerprint, + None, + &ts_token, + false, + EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, + false, + false, + ) + .await; + assert_eq!(request_class, PlaybackRequestClass::Activate); + assert_eq!(ts_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(ts_grace_mode, None); + assert!( + app_state + .active_users + .get_and_update_user_session(&user.username, &hls_token) + .await + .is_none(), + "the competing TS activation must remove the old preserved HLS session even though there is no live socket left to kick" + ); + assert_eq!( + app_state.active_users.user_connections(&user.username).await, + 0, + "eviction must not leave a real slot before the TS stream commits" + ); + assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0)); + + app_state.connection_manager.add_connection(&ts_addr).await; + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user: &user, + session_token: &ts_token, + virtual_id, + provider: "provider_1", + stream_url: ts_channel.url.as_ref(), + addr: &ts_addr, + connection_permission: ts_admission.permission, + connection_kind: ts_admission.kind, + socket_bound: true, + }) + .await; + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 2, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: crate::api::model::ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &ts_fingerprint, + provider: "provider_1".intern(), + stream_channel: &ts_channel, + user_agent: Cow::Borrowed("libmpv"), + session_token: Some(&ts_token), + }) + .await; + + assert_eq!(app_state.active_users.user_connections(&user.username).await, 1); + assert_eq!(app_state.active_users.active_users_and_connections().await, (1, 1)); + let active_streams = app_state.active_users.active_streams().await; + assert_eq!(active_streams.len(), 1); + assert_eq!(active_streams.first().and_then(|stream| stream.session_token.as_deref()), Some(ts_token.as_str())); + assert!( + app_state + .active_users + .get_and_update_user_session(&user.username, &hls_token) + .await + .is_none(), + "after the competing TS request, the old Xtream HLS session must be gone so later /hls segment fetches cannot revive it" + ); + assert!( + app_state.active_users.get_and_update_user_session(&user.username, &ts_token).await.is_some(), + "the winning TS playback should remain tracked under its socket-bound Xtream token" + ); +} + +#[test] +fn socket_bound_playback_session_matches_only_plain_live_playback() { + assert!(is_socket_bound_playback_session(PlaylistItemType::Live, None)); + assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(HLS_EXT))); + assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(DASH_EXT))); + assert!(!is_socket_bound_playback_session(PlaylistItemType::LiveHls, None)); + assert!(!is_socket_bound_playback_session(PlaylistItemType::Video, None)); + assert!(!is_socket_bound_playback_session(PlaylistItemType::Series, None)); + assert!(!is_socket_bound_playback_session(PlaylistItemType::Catchup, None)); +} + +#[test] +fn session_reacquire_cleanup_addrs_excludes_current_and_deduplicates() { + let primary: SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!()); + let overlap: SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!()); + let seek: SocketAddr = "127.0.0.1:55193".parse().unwrap_or_else(|_| unreachable!()); + let session = UserSession { + token: "tok-vod".to_string(), + transition_version: 1, + 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], + ts: 1, + started_at: 1, + permission: UserConnectionPermission::Allowed, + connection_kind: Some(crate::api::model::ConnectionKind::Normal), + lifecycle: crate::api::model::PlaybackLifecycle::Active, + }; + + assert_eq!(session_reacquire_cleanup_addrs(&session, &seek), vec![primary, overlap]); +} + +#[tokio::test] +async fn intentional_deferred_open_retains_provider_grace_handle() { + let app_state = create_test_provider_app_state(); + let provider_name = "provider_1".intern(); + let holder_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); + let deferred_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); + let holder_handle = app_state + .active_provider + .acquire_exact_connection_with_grace( + &provider_name, + &holder_addr, + false, + 0, + crate::api::model::ConnectionKind::Normal, + ) + .await + .expect("holder occupies the provider slot"); + let input = app_state.app_config.get_input_by_name(&provider_name).expect("provider input"); + let stream_url = "http://provider-1.example/live/user1/pass1/100.m3u8"; + let mut channel = create_test_live_channel(stream_url); + channel.item_type = PlaylistItemType::LiveHls; + let fingerprint = create_test_fingerprint(deferred_addr); + + let mut details = create_stream_response_details( + &app_state, + &get_stream_options(&app_state.app_config), + stream_url, + "deferred-user", + &fingerprint, + &HeaderMap::new(), + &input, + &channel, + PlaylistItemType::LiveHls, + crate::api::model::ProviderContentRepresentationMode::PreserveOrigin, + false, + UserConnectionPermission::Allowed, + None, + true, + true, + VirtualId::new(channel.virtual_id), + 0, + crate::api::model::ConnectionKind::Normal, + false, + Some("deferred-session"), + None, + false, + Some(true), + None, + ) + .await + .expect("provider grace creates deferred stream details"); + + assert!(details.stream.is_none()); + assert!(details.has_deferred_provider_open()); + assert!(details.provider_handle.is_some(), "deferred open must retain its provider allocation"); + + app_state.connection_manager.release_provider_handle(details.provider_handle.take()).await; + app_state.connection_manager.release_provider_handle(Some(holder_handle)).await; +} + +#[test] +fn grace_hold_defers_live_and_fresh_video_but_not_catchup_or_affine_reopens() { + assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::LiveHls, false)); + assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, false)); + assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, false)); + assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, true)); + assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, true)); + assert!(!should_defer_provider_open_for_grace_hold(true, false, PlaylistItemType::Video, true)); +} + +#[tokio::test] +async fn forced_reopen_cleanup_for_adaptive_streams_does_not_close_client_socket() { + let app_state = create_test_app_state(); + let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!()); + let mut close_rx = app_state.connection_manager.get_close_connection_channel(); + + cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::LiveHls, &[addr]).await; + + let signal = + tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok); + assert!(signal.is_none(), "adaptive cleanup should not hard-close the previous client socket"); +} + +#[tokio::test] +async fn forced_reopen_cleanup_for_non_adaptive_streams_closes_client_socket() { + let app_state = create_test_app_state(); + let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!()); + let mut close_rx = app_state.connection_manager.get_close_connection_channel(); + + cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::Live, &[addr]).await; + + let signal = + tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok); + assert!(matches!( + signal, + Some(crate::api::model::CloseConnectionSignal::WithReason(signal_addr, _)) if signal_addr == addr + )); +} + +#[tokio::test] +async fn get_query_path_strips_extension_for_live_with_flag() { + use crate::model::ConfigInputFlags; + use shared::model::{InputType, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; + + let mut input = ConfigInput { + id: 1, + name: "provider_with_flag".intern(), + input_type: InputType::Xtream, + ..ConfigInput::default() + }; + let mut options = crate::model::ConfigInputOptions::defaults().clone(); + options.flags.set(ConfigInputFlags::XtreamLiveStreamWithoutExtension); + input.options = Some(options); + + let sources = SourcesConfig { inputs: vec![Arc::new(input)], ..SourcesConfig::default() }; + let mut app_cfg_raw = create_test_app_config(); + app_cfg_raw.sources = Arc::new(ArcSwap::from_pointee(sources)); + let app_state = create_test_app_state_for_config(Arc::new(app_cfg_raw)); + + let pli = XtreamPlaylistItem { + virtual_id: VirtualId::new(100), + provider_id: 1, + name: "test".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "http://example.com/123".intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Live, + additional_properties: None, + item_type: PlaylistItemType::Live, + category_id: 0, + input_name: "provider_with_flag".intern(), + channel_no: 0, + source_ordinal: 0, + input_stream_id: "1".intern(), + upstream_user_agent: None, + }; + + let hls_ext = shared::defaults::HLS_EXT.to_string(); + let (query_path, extension) = + crate::api::endpoints::xtream_api::get_query_path("", Some(&hls_ext), &pli, &app_state); + + assert_eq!(extension, ""); + assert_eq!(query_path, "1"); + + let dash_ext = shared::defaults::DASH_EXT.to_string(); + let (query_path, extension) = + crate::api::endpoints::xtream_api::get_query_path("", Some(&dash_ext), &pli, &app_state); + + assert_eq!(extension, ""); + assert_eq!(query_path, "1"); +} + +// ========================================================================================= +// evaluate_network_access tests +// ========================================================================================= + +/// Run `evaluate_network_access` for a synthetic user built from +/// `network_access` and assert the decision matches `expected`. Centralizes +/// the boilerplate (`user_with_network_access` + geoip setup + call + +/// assert) shared by every `evaluate_network_access` test below. +fn assert_network_decision( + network_access: Option, + geoip: &Arc>, + ip: &str, + expected: NetworkAccessDecision, +) { + let user = user_with_network_access(network_access); + assert_eq!(evaluate_network_access(&user, ip, geoip, GeoIpUnavailablePolicy::Deny), expected); +} + +/// `Arc>` with no `GeoIP` database loaded. +fn empty_geoip() -> Arc> { Arc::new(ArcSwapOption::::default()) } + +/// `Arc>` with a mock `GeoIP` that always reports the +/// given country for any lookup. +fn mock_geoip(country: &str) -> Arc> { + Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new(country))))) +} + +/// Helper to create a test user with specific network access +fn user_with_network_access(network_access: Option) -> ProxyUserCredentials { + ProxyUserCredentials { + username: "test".to_string(), + password: "test".to_string(), + token: None, + proxy: ProxyType::default(), + server: None, + epg_timeshift: None, + epg_request_timeshift: None, + created_at: None, + exp_date: None, + max_connections: 0, + status: None, + output_clusters: ClusterFlags::all(), + ui_enabled: true, + comment: None, + priority: 0, + soft_connections: 0, + soft_priority: 0, + t_is_api_user: false, + network_access, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 0, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::default()), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, + } +} + +#[test] +fn no_config_allows_all() { + assert_network_decision(None, &empty_geoip(), "192.168.1.1", NetworkAccessDecision::Allowed); +} + +#[test] +fn empty_config_allows_all() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec![] }), + &empty_geoip(), + "192.168.1.1", + NetworkAccessDecision::Allowed, + ); +} + +#[test] +fn cidr_match_allows() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }), + &empty_geoip(), + "192.168.1.42", + NetworkAccessDecision::Allowed, + ); +} + +#[test] +fn cidr_miss_denies() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }), + &empty_geoip(), + "10.0.0.1", + NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), + ); +} + +#[test] +fn country_match_allows() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), + &mock_geoip("DE"), + "8.8.8.8", + NetworkAccessDecision::Allowed, + ); +} + +#[test] +fn country_miss_denies() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), + &mock_geoip("US"), + "8.8.8.8", + NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch), + ); +} + +#[test] +fn no_geoip_denies_on_country_restriction() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }), + &empty_geoip(), + "8.8.8.8", + NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable), + ); +} + +#[test] +fn ipv4_vs_ipv6_denies_gracefully() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["2001:db8::/32".parse().unwrap()] }), + &empty_geoip(), + "192.168.1.1", + NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), + ); +} + +#[test] +fn ipv6_vs_ipv4_denies_gracefully() { + assert_network_decision( + Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }), + &empty_geoip(), + "2001:db8::1", + NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch), + ); +} + +#[test] +fn either_cidr_or_country_match_allows() { + assert_network_decision( + Some(NetworkAccess { + allowed_countries: vec!["US".to_string()], + allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], + }), + &mock_geoip("DE"), + "192.168.1.42", + NetworkAccessDecision::Allowed, + ); +} + +#[test] +fn single_ip_cidr() { + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec![], + allowed_networks: vec!["192.168.1.1/32".parse().unwrap()], + })); + let geoip = empty_geoip(); + assert_eq!( + evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Allowed + ); + assert_eq!( + evaluate_network_access(&user, "192.168.1.2", &geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch) + ); +} + +// ========================================================================================= +// network denied reason tests +// ========================================================================================= + +// The three `network_denied_reason_*` cases remain because they cover the +// `NetworkAccessDenyReason`-focused API surface directly, while +// `cidr_miss_denies`, `country_miss_denies`, and +// `no_geoip_denies_on_country_restriction` above assert the same deny +// reasons through broader `evaluate_network_access(...)` behavior. The +// overlap is intentional so both the general decision path and the +// reason-reporting-focused path stay pinned by tests. + +#[test] +fn network_denied_reason_country_unknown_when_geoip_loaded_but_unknown_ip() { + // GeoIP is loaded (not None), but lookup returns None for this IP (private/unknown). + // We need a GeoIP that only covers a private range, so public IPs get None. + // Use a CIDR-only restriction (no country rules) so we can verify + // that when countries ARE checked, lookup None gives "country_unknown". + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], // miss CIDR first + })); + // Use the real GeoIp::new() which only seeds private ranges. + // For 8.8.8.8 (public), lookup returns None. + let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new())))); + // CIDR miss -> country check -> geoip loaded but lookup returns None + assert_eq!( + evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown) + ); +} + +#[test] +fn network_denied_reason_none_when_allowed() { + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec![], + })); + let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE"))))); + assert_eq!( + evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Allowed + ); +} + +#[test] +fn network_denied_reason_none_when_no_config() { + let user = user_with_network_access(None); + let geoip = Arc::new(ArcSwapOption::::default()); + assert_eq!( + evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Allowed + ); +} + +// ========================================================================================= +// GeoIP unavailable policy tests +// ========================================================================================= + +#[test] +fn geoip_unavailable_default_deny_denies() { + // Country rule exists but GeoIP is unavailable — default policy is Deny + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec![], + })); + let geoip = Arc::new(ArcSwapOption::::default()); + let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny); + assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)); +} + +#[test] +fn geoip_unavailable_explicit_allow_allows() { + // Country rule exists, GeoIP unavailable, but policy is Allow — allows + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec![], + })); + let geoip = Arc::new(ArcSwapOption::::default()); + let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow); + assert_eq!(decision, NetworkAccessDecision::AllowedGeoIpUnavailable); +} + +#[test] +fn geoip_unavailable_cidr_only_still_denies() { + // CIDR only rules, no match — should deny even with Allow policy + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec![], + allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], + })); + let geoip = Arc::new(ArcSwapOption::::default()); + let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Allow); + assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)); +} + +#[test] +fn geoip_unavailable_cidr_match_allows() { + // CIDR match always allows, regardless of policy + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec!["10.0.0.0/8".parse().unwrap()], + })); + let geoip = Arc::new(ArcSwapOption::::default()); + let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny); + assert_eq!(decision, NetworkAccessDecision::Allowed); +} + +#[test] +fn geoip_loaded_country_mismatch_still_denies() { + // Loaded GeoIP but country doesn't match — should deny under Allow policy + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec![], + })); + let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US"))))); + let decision = evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Allow); + assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch)); +} + +#[test] +fn geoip_loaded_unknown_country_still_denies() { + // Loaded GeoIP but lookup returns None — should deny under Allow policy + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], + })); + let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new())))); + let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow); + assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown)); +} + +#[test] +fn malformed_ip_denies_even_when_geoip_unavailable_policy_is_allow() { + let user = user_with_network_access(Some(NetworkAccess::from(&shared::model::NetworkAccessDto { + allowed_countries: Some(vec!["DE".to_string()]), + allowed_networks: None, + }))); + let geoip = Arc::new(ArcSwapOption::::default()); + + let decision = evaluate_network_access(&user, "not-an-ip", &geoip, GeoIpUnavailablePolicy::Allow); + + assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::MalformedClientIp)); +} + +#[test] +fn evaluate_network_access_respects_allow_policy() { + // verify evaluate_network_access returns AllowedGeoIpUnavailable with Allow policy + let user = user_with_network_access(Some(NetworkAccess { + allowed_countries: vec!["DE".to_string()], + allowed_networks: vec![], + })); + let geoip = Arc::new(ArcSwapOption::::default()); + // Default deny policy should return Denied + assert_eq!( + evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny), + NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable) + ); + // Allow policy should return AllowedGeoIpUnavailable + assert_eq!( + evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow), + NetworkAccessDecision::AllowedGeoIpUnavailable + ); +} diff --git a/backend/app/src/api/config_file.rs b/backend/app/src/api/config_file.rs index cdceb9539..e84b3feb8 100644 --- a/backend/app/src/api/config_file.rs +++ b/backend/app/src/api/config_file.rs @@ -295,16 +295,12 @@ impl ConfigFile { let mut config: Config = Config::from(config_dto); config.prepare(paths.config_path.as_str(), paths.home_path.as_str())?; - // Compute effective runtime paths for the NEW config before apply. - // This ensures prepare-phase reads/validates against the same files that will be active after apply. + // Resolve the paths that will be active if this configuration is applied. let mut effective_paths = paths.as_ref().clone(); effective_paths.mapping_file_path = Some(next_mapping_path.clone()); effective_paths.template_file_path = Some(next_template_path.clone()); - // ── PREPARE PHASE ──────────────────────────────────────────── - // All dependent data is loaded/validated here, using the NEW config values - // but without touching any live state. If anything fails, we return an error - // and the currently-running state remains completely unchanged. + // Load and validate dependent data without changing live state. let follow_up: PreparedFollowUp = if template_changed { // Template path changed -> sources depend on new templates, reload everything. let prepared = Self::prepare_sources_reload_with_config(&config, &effective_paths).await?; @@ -326,8 +322,7 @@ impl ConfigFile { let previous_sources: SourcesConfig = (*app_state.app_config.sources.load_full()).clone(); let previous_forced_targets = app_state.forced_targets.load_full(); - // ── APPLY PHASE ────────────────────────────────────────────── - // All preparation succeeded — safe to update live state now. + // Update live state only after every dependent value has been prepared. if let Err(err) = update_app_state_config(app_state, config).await { error!("Failed to apply config reload: {err}. Attempting config rollback."); if let Err(rollback_err) = update_app_state_config(app_state, previous_config).await { diff --git a/backend/app/src/api/endpoints/custom_video_stream_api.rs b/backend/app/src/api/endpoints/custom_video_stream_api.rs index ab8c81483..6b74fd73f 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -729,6 +729,7 @@ mod tests { shared_stream_manager, hls_proxy: Arc::new(HlsProxyManager::new()), hls_provisioning: Arc::new(HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, diff --git a/backend/app/src/api/endpoints/download_api.rs b/backend/app/src/api/endpoints/download_api/mod.rs similarity index 72% rename from backend/app/src/api/endpoints/download_api.rs rename to backend/app/src/api/endpoints/download_api/mod.rs index 4f5c1bfe7..abfa7d1ae 100644 --- a/backend/app/src/api/endpoints/download_api.rs +++ b/backend/app/src/api/endpoints/download_api/mod.rs @@ -2141,866 +2141,4 @@ pub async fn retry_download( } #[cfg(test)] -mod tests { - use super::{ - active_download_snapshot, active_download_snapshot_for_worker, broadcast_download_queue_update, - broadcast_required_worker_mutation, broadcast_worker_mutation, cancel_active_and_promote, cancel_download, - commit_acquired_download, finish_active_and_promote, mark_recording_notification, parse_content_range_total, - pause_download, preemption_reason_for, recording_deadline_reached, recording_execution_download, - refresh_recording_progress, requeue_active_download_for_capacity_wait, requeue_active_download_for_retry, - resume_download, retryable_transport_error_message, rollback_last_recording_marker, set_active_download_state, - should_exit_worker_after_preempt, DownloadActionRequest, DOWNLOAD_PREEMPTED_REASON, RECORDING_PREEMPTED_REASON, - }; - use crate::{ - api::model::{ - recording_notification::LifecycleEvent, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, - ConnectionManager, DownloadControl, DownloadKind, DownloadQueue, DownloadState, EventManager, EventMessage, - FileDownload, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard, - }, - model::{ - ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, MediaToolCapabilities, MessageContent, - ProcessTargets, SourcesConfig, - }, - repository::GeoIp, - utils::FileLockManager, - }; - use arc_swap::{ArcSwap, ArcSwapOption}; - use axum::response::IntoResponse; - use reqwest::header::{HeaderMap, HeaderValue}; - use shared::{ - model::{ - ConfigPaths, InputFetchMethod, InputType, RecordingMetadata, RecordingOwner, RecordingSource, - RecordingVisibility, UserId, - }, - utils::Internable, - }; - use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; - use tokio::sync::{mpsc, RwLock}; - - fn make_download( - kind: DownloadKind, - state: DownloadState, - start_at: Option, - duration_secs: Option, - ) -> FileDownload { - FileDownload { - uuid: "id".to_string(), - file_dir: PathBuf::from("/tmp"), - file_path: PathBuf::from("/tmp/file.ts"), - filename: "file.ts".to_string(), - url: reqwest::Url::parse("https://example.com/file.ts").expect("valid url"), - finished: false, - size: 128, - total_size: Some(1024), - paused: false, - error: Some("transient".to_string()), - state, - start_at, - duration_secs, - kind, - input_name: None, - priority: 0, - retry_attempts: 0, - next_retry_at: None, - recording: None, - } - } - - fn attach_recording(download: &mut FileDownload, owner: RecordingOwner, visibility: RecordingVisibility) { - let mut metadata = - RecordingMetadata::new(owner, visibility, RecordingSource::new("1", "42", "input-a"), 1_000, 1_060, 0, 0); - metadata.program_title = Some("Programme".to_string()); - metadata.channel_name = Some("Channel".to_string()); - metadata.relative_path = Some("Channel/Programme.ts".to_string()); - download.recording = Some(metadata); - } - - #[test] - fn recording_notification_marker_is_at_most_once() { - let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); - attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); - - let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); - let duplicate = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); - - assert!(matches!(first.message, Some(MessageContent::RecordingLifecycle(_)))); - assert!(duplicate.message.is_none()); - assert_eq!(download.recording.as_ref().map_or(0, |metadata| metadata.notification_markers.len()), 1); - } - - #[test] - fn private_user_recording_notification_is_suppressed() { - let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); - attach_recording(&mut download, RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Private); - - let message = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); - - assert!(message.message.is_none()); - assert_eq!(download.recording.as_ref().map_or(0, |metadata| metadata.notification_markers.len()), 0); - } - - #[test] - fn rollback_last_recording_marker_removes_most_recent_matching_kind() { - let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); - attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); - - // Two distinct marker kinds end up in the same metadata after a - // successful Completed followed by a Failed on the same task — this - // mirrors what would happen in production across two persist rounds. - let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); - let second = - mark_recording_notification(&mut download, LifecycleEvent::Failed, Some("ffmpeg exited".to_string())); - assert!(first.marker_kind.is_some()); - assert!(second.marker_kind.is_some()); - - let kind = second.marker_kind.unwrap(); - rollback_last_recording_marker(&mut download, &kind); - - let markers = &download.recording.as_ref().unwrap().notification_markers; - assert_eq!(markers.len(), 1, "only the Completed marker should remain"); - assert!(matches!(markers[0].kind, shared::model::recording::NotificationMarkerKind::Completed)); - } - - #[test] - fn recording_deadline_uses_start_plus_duration() { - let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); - let normal = make_download(DownloadKind::Download, DownloadState::Downloading, Some(1_000), Some(60)); - - assert!(!recording_deadline_reached(&recording, 1_059)); - assert!(recording_deadline_reached(&recording, 1_060)); - assert!(!recording_deadline_reached(&normal, 1_060)); - } - - #[test] - fn recording_execution_requires_metadata_source() { - let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); - - let result = recording_execution_download(&create_test_app_config(), &recording); - - assert_eq!(result.as_ref().err().map(String::as_str), Some("Recording source metadata missing")); - } - - #[test] - fn recording_execution_uses_fresh_token_without_mutating_persisted_descriptor() { - let mut recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); - recording.url = reqwest::Url::parse( - "tuliprox-recording://source?target_name=stable-target&input_name=provider_1&virtual_id=42&cluster=live", - ) - .expect("valid descriptor"); - attach_recording(&mut recording, RecordingOwner::LegacyAdmin, RecordingVisibility::Private); - let source = - recording.recording.as_mut().and_then(|metadata| metadata.source.as_mut()).expect("recording source"); - source.target_id = "stable-target".to_string(); - source.virtual_id = "42".to_string(); - source.input_name = "provider_1".to_string(); - let persisted_before = DownloadQueue::to_persisted(&recording); - let app_config = create_test_app_config(); - - let execution = recording_execution_download(&app_config, &recording).expect("execution download"); - let token = execution - .url - .path_segments() - .and_then(|segments| segments.collect::>().get(4).copied()) - .expect("route token"); - - assert!(crate::auth::verify_access_token( - token, - &app_config.access_token_secret, - crate::auth::scope::INTERNAL_PLAYER - )); - assert_eq!(recording.url.as_str(), persisted_before.url); - assert_eq!(DownloadQueue::to_persisted(&recording).url, persisted_before.url); - assert_ne!(execution.url, recording.url); - } - - #[tokio::test] - async fn retry_requeues_active_download_at_front_in_one_commit() { - let dir = tempfile::tempdir().expect("tempdir"); - let state_file = dir.path().join("downloads_state.json"); - let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); - let queued = make_download(DownloadKind::Download, DownloadState::Queued, None, None); - let active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); - - queue.queue.lock().await.push_back(queued); - *queue.active.write().await = Some(active); - - requeue_active_download_for_retry(&queue, "id", false).await.expect("requeue retry"); - - assert!(queue.active.read().await.is_none()); - let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); - assert_eq!(queued_items.len(), 2); - assert_eq!(queued_items[0].state, DownloadState::Queued); - assert_eq!(queued_items[0].size, 128); - assert!(queued_items[0].error.is_none()); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); - let persisted: crate::api::model::PersistedDownloadQueue = - serde_json::from_slice(&std::fs::read(state_file).expect("read state")).expect("parse state"); - assert_eq!(persisted.revision, shared::model::QueueRevision(1)); - } - - #[tokio::test] - async fn preempted_active_download_requeues_to_capacity_wait_with_partial_progress() { - let queue = DownloadQueue::new(); - let mut active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); - active.size = 512; - active.total_size = Some(2048); - *queue.active.write().await = Some(active); - - requeue_active_download_for_capacity_wait(&queue, "id", DOWNLOAD_PREEMPTED_REASON, false, None) - .await - .expect("requeue capacity wait"); - - assert!(queue.active.read().await.is_none()); - let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); - assert_eq!(queued_items.len(), 1); - assert_eq!(queued_items[0].state, DownloadState::WaitingForCapacity); - assert_eq!(queued_items[0].size, 512); - assert_eq!(queued_items[0].total_size, Some(2048)); - assert_eq!(queued_items[0].error.as_deref(), Some(DOWNLOAD_PREEMPTED_REASON)); - } - - #[tokio::test] - async fn terminal_transition_finishes_active_and_promotes_next_in_one_commit() { - let dir = tempfile::tempdir().expect("tempdir"); - let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); - *queue.active.write().await = - Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); - let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); - next.uuid = "next".to_string(); - queue.queue.lock().await.push_back(next); - - finish_active_and_promote(&queue, "id", |finished| { - finished.finished = true; - finished.state = DownloadState::Completed; - super::RecordingNotificationPlan::empty() - }) - .await - .expect("terminal commit"); - - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!(queue.finished.read().await.len(), 1); - assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("next")); - } - - #[tokio::test] - async fn worker_mutation_failure_keeps_memory_and_revision_unchanged() { - let dir = tempfile::tempdir().expect("tempdir"); - let blocking_dir = dir.path().join("state"); - std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); - let queue = DownloadQueue::new_with_state_file(Some(blocking_dir)); - *queue.active.write().await = - Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); - - let result = requeue_active_download_for_retry(&queue, "id", false).await; - - assert!(result.is_err()); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - assert!(queue.queue.lock().await.is_empty()); - assert_eq!( - queue.active.read().await.as_ref().map(|active| active.state.clone()), - Some(DownloadState::Downloading) - ); - } - - #[tokio::test] - async fn preempted_active_recording_requeues_with_recording_specific_policy_message() { - let queue = DownloadQueue::new(); - let mut active = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(600)); - active.size = 512; - *queue.active.write().await = Some(active); - - requeue_active_download_for_capacity_wait(&queue, "id", RECORDING_PREEMPTED_REASON, false, None) - .await - .expect("requeue recording"); - - let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); - assert_eq!(queued_items.len(), 1); - assert_eq!(queued_items[0].kind, DownloadKind::Recording); - assert_eq!(queued_items[0].state, DownloadState::WaitingForCapacity); - assert_eq!(queued_items[0].error.as_deref(), Some(RECORDING_PREEMPTED_REASON)); - } - - #[test] - fn preemption_reason_is_explicit_for_recordings_and_downloads() { - let download = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); - let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); - - assert_eq!(preemption_reason_for(&download), DOWNLOAD_PREEMPTED_REASON); - assert_eq!(preemption_reason_for(&recording), RECORDING_PREEMPTED_REASON); - } - - #[test] - fn only_restart_exits_worker_after_preempt() { - assert!(!should_exit_worker_after_preempt(DownloadControl::None)); - assert!(!should_exit_worker_after_preempt(DownloadControl::Pause)); - assert!(!should_exit_worker_after_preempt(DownloadControl::Cancel)); - assert!(should_exit_worker_after_preempt(DownloadControl::Restart)); - } - - #[tokio::test] - async fn set_active_download_state_updates_snapshot_state() { - let queue = DownloadQueue::new(); - let active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); - *queue.active.write().await = Some(active); - - let changed = set_active_download_state( - &queue, - "id", - DownloadState::WaitingForCapacity, - Some("waiting".to_string()), - false, - ) - .await; - - assert!(changed.expect("set active state")); - let active = queue.active.read().await.clone().expect("active download"); - assert_eq!(active.state, DownloadState::WaitingForCapacity); - assert_eq!(active.error.as_deref(), Some("waiting")); - assert!(!active.paused); - } - - #[tokio::test] - async fn acquisition_without_provider_handle_commits_downloading_state() { - let queue = DownloadQueue::new(); - *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Queued, None, None)); - - let notification = commit_acquired_download(&queue, "id").await.expect("acquired commit"); - - assert!(notification.is_some()); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!( - queue.active.read().await.as_ref().map(|active| active.state.clone()), - Some(DownloadState::Downloading) - ); - } - - #[tokio::test] - async fn acquired_transition_rejects_switched_active_task() { - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let queue = DownloadQueue::new(); - let mut switched = make_download(DownloadKind::Recording, DownloadState::Queued, None, None); - switched.uuid = "task-b".to_string(); - attach_recording(&mut switched, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); - *queue.active.write().await = Some(switched); - - let transition = commit_acquired_download(&queue, "task-a").await.map(|notification| notification.is_some()); - let result = - broadcast_required_worker_mutation(&event_manager, &queue, transition, "acquired downloading state").await; - - assert!(result.is_err()); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("task-b")); - assert_eq!( - queue - .active - .read() - .await - .as_ref() - .and_then(|active| active.recording.as_ref()) - .map_or(0, |recording| recording.notification_markers.len()), - 0 - ); - assert!(events.try_recv().is_err()); - } - - #[tokio::test] - async fn post_acquire_snapshot_rejects_switched_active_task() { - let queue = DownloadQueue::new(); - let mut switched = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); - switched.uuid = "task-b".to_string(); - *queue.active.write().await = Some(switched); - - assert!(active_download_snapshot_for_worker(&queue.active, "task-a").await.is_none()); - } - - #[tokio::test] - async fn stale_worker_progress_does_not_update_switched_active_task() { - let queue = DownloadQueue::new(); - let mut switched = make_download(DownloadKind::Recording, DownloadState::Downloading, None, None); - switched.uuid = "task-b".to_string(); - switched.size = 10; - *queue.active.write().await = Some(switched); - let dir = tempfile::tempdir().expect("tempdir"); - let progress_path = dir.path().join("task-a.ts.part"); - std::fs::write(&progress_path, [0_u8; 20]).expect("progress file"); - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - - refresh_recording_progress(&queue.active, "task-a", &progress_path, &event_manager).await; - - assert_eq!(queue.active.read().await.as_ref().map(|active| active.size), Some(10)); - assert!(events.try_recv().is_err()); - } - - #[test] - fn compute_download_retry_backoff_uses_multiplier_and_cap() { - let download_cfg = crate::model::VideoDownloadConfig { - headers: std::collections::HashMap::new(), - directory: "/tmp".to_string(), - organize_into_directories: false, - episode_pattern: None, - download_priority: 0, - recording_priority: 0, - reserve_slots_for_users: 0, - max_background_per_provider: 0, - retry_backoff_initial_secs: 3, - retry_backoff_multiplier: 3.0, - retry_backoff_max_secs: 30, - retry_backoff_jitter_percent: 0, - retry_max_attempts: 5, - recording: None, - }; - - assert_eq!(super::compute_download_retry_backoff_secs(1, &download_cfg), 3); - assert_eq!(super::compute_download_retry_backoff_secs(2, &download_cfg), 9); - assert_eq!(super::compute_download_retry_backoff_secs(3, &download_cfg), 27); - assert_eq!(super::compute_download_retry_backoff_secs(8, &download_cfg), 30); - } - - #[test] - fn background_download_waits_when_all_candidates_hit_background_limit() { - let download_cfg = crate::model::VideoDownloadConfig { - headers: std::collections::HashMap::new(), - directory: "/tmp".to_string(), - organize_into_directories: false, - episode_pattern: None, - download_priority: 0, - recording_priority: 0, - reserve_slots_for_users: 0, - max_background_per_provider: 2, - retry_backoff_initial_secs: 3, - retry_backoff_multiplier: 3.0, - retry_backoff_max_secs: 30, - retry_backoff_jitter_percent: 0, - retry_max_attempts: 5, - recording: None, - }; - - let capacities = vec![(Arc::::from("a"), 2, 5), (Arc::::from("b"), 3, 5)]; - assert!(super::background_download_should_wait(1, &capacities, &download_cfg)); - assert!(!super::background_download_should_wait(0, &capacities, &download_cfg)); - } - - #[test] - fn background_download_waits_when_reserved_user_slots_would_be_consumed() { - let download_cfg = crate::model::VideoDownloadConfig { - headers: std::collections::HashMap::new(), - directory: "/tmp".to_string(), - organize_into_directories: false, - episode_pattern: None, - download_priority: 0, - recording_priority: 0, - reserve_slots_for_users: 1, - max_background_per_provider: 0, - retry_backoff_initial_secs: 3, - retry_backoff_multiplier: 3.0, - retry_backoff_max_secs: 30, - retry_backoff_jitter_percent: 0, - retry_max_attempts: 5, - recording: None, - }; - - let blocked = vec![(Arc::::from("a"), 4, 5), (Arc::::from("b"), 4, 5)]; - let allowed = vec![(Arc::::from("a"), 3, 5), (Arc::::from("b"), 4, 6)]; - assert!(super::background_download_should_wait(1, &blocked, &download_cfg)); - assert!(!super::background_download_should_wait(1, &allowed, &download_cfg)); - } - - #[test] - fn retryable_transport_error_message_detects_common_transient_failures() { - assert!(retryable_transport_error_message("dns lookup failed")); - assert!(retryable_transport_error_message("connection reset by peer")); - assert!(retryable_transport_error_message("operation timed out")); - assert!(!retryable_transport_error_message("invalid URL")); - } - - #[tokio::test] - async fn active_download_snapshot_releases_read_lock_before_followup_write() { - let active = Arc::new(RwLock::new(Some(FileDownload { - uuid: "id".to_string(), - file_dir: PathBuf::from("/tmp"), - file_path: PathBuf::from("/tmp/file.bin"), - filename: "deadlock-test.bin".to_string(), - url: reqwest::Url::parse("https://example.com/file.bin").expect("valid url"), - finished: false, - size: 0, - total_size: None, - paused: false, - error: None, - state: DownloadState::Downloading, - start_at: None, - duration_secs: None, - kind: DownloadKind::Download, - input_name: None, - priority: 0, - retry_attempts: 0, - next_retry_at: None, - recording: None, - }))); - let snapshot = active_download_snapshot(&active).await; - assert!(snapshot.is_some()); - - let write_result = tokio::time::timeout(Duration::from_millis(100), active.write()).await; - assert!(write_result.is_ok(), "write lock should not be blocked by snapshot helper"); - } - - #[test] - fn parse_content_range_total_extracts_full_size() { - let mut headers = HeaderMap::new(); - headers.insert("content-range", HeaderValue::from_static("bytes 512-1023/4096")); - - assert_eq!(parse_content_range_total(&headers), Some(4096)); - } - - fn create_test_app_config() -> AppConfig { - let input = Arc::new(ConfigInput { - id: 1, - name: "provider_1".intern(), - input_type: InputType::Xtream, - headers: HashMap::default(), - url: "http://provider-1.example".to_string(), - username: Some("user1".to_string()), - password: Some("pass1".to_string()), - enabled: true, - priority: 0, - max_connections: 1, - method: InputFetchMethod::default(), - aliases: None, - ..ConfigInput::default() - }); - let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; - - AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config::default())), - sources: Arc::new(ArcSwap::from_pointee(sources)), - hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::from(Some(Arc::new(ApiProxyConfig { - server: vec![ApiProxyServerInfo { - name: "default".to_string(), - protocol: "http".to_string(), - host: "player.example".to_string(), - port: None, - timezone: "UTC".to_string(), - message: String::new(), - path: None, - }], - ..ApiProxyConfig::default() - })))), - file_locks: Arc::new(FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - })), - custom_stream_response: Arc::new(ArcSwapOption::default()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(MediaToolCapabilities::new()), - } - } - - fn create_test_app_state_with_downloads(downloads: Arc) -> Arc { - let app_cfg = Arc::new(create_test_app_config()); - let event_manager = Arc::new(EventManager::new()); - let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); - let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); - active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); - - let geoip = Arc::new(ArcSwapOption::::default()); - let config = app_cfg.config.load(); - let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - let connection_manager = Arc::new(ConnectionManager::new( - &active_users, - &active_provider, - &shared_stream_manager, - &event_manager, - None, - )); - - let tokens = CancelTokens::default(); - let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); - let (manual_update_sender, _) = mpsc::channel::(1); - - Arc::new(AppState { - forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { - enabled: false, - inputs: Vec::new(), - targets: Vec::new(), - target_names: Vec::new(), - })), - app_config: app_cfg, - http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - downloads, - cache: Arc::new(ArcSwapOption::default()), - shared_stream_manager, - hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), - hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), - active_users, - active_provider, - connection_manager, - event_manager, - cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), - playlists: Arc::new(PlaylistStorageState::new()), - geoip, - update_guard: UpdateGuard::new(), - metadata_manager, - identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( - std::path::PathBuf::new(), - )), - login_throttle: Arc::new(crate::auth::LoginThrottle::new()), - token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( - std::path::PathBuf::new(), - )), - manual_update_sender, - }) - } - - fn create_test_app_state() -> Arc { create_test_app_state_with_downloads(Arc::new(DownloadQueue::new())) } - - #[tokio::test] - async fn pause_persist_failure_returns_error_without_event_or_memory_change() { - let dir = tempfile::tempdir().expect("tempdir"); - let blocking_dir = dir.path().join("state"); - std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); - let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); - *downloads.active.write().await = - Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); - let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); - let mut events = app_state.event_manager.get_event_channel(); - - let response = pause_download( - axum::extract::State(app_state), - axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), - ) - .await - .into_response(); - - assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); - assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); - assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - let active = downloads.active.read().await; - assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Downloading)); - assert_eq!(active.as_ref().map(|download| download.paused), Some(false)); - } - - #[tokio::test] - async fn resume_persist_failure_returns_error_without_event_or_memory_change() { - let dir = tempfile::tempdir().expect("tempdir"); - let blocking_dir = dir.path().join("state"); - std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); - let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); - let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); - paused.paused = true; - *downloads.active.write().await = Some(paused); - let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); - let mut events = app_state.event_manager.get_event_channel(); - - let response = resume_download( - axum::extract::State(app_state), - axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), - ) - .await - .into_response(); - - assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); - assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); - assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - let active = downloads.active.read().await; - assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); - assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); - } - - #[tokio::test] - async fn paused_cancel_persist_failure_returns_error_without_event_or_memory_change() { - let dir = tempfile::tempdir().expect("tempdir"); - let blocking_dir = dir.path().join("state"); - std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); - let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); - let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); - paused.paused = true; - *downloads.active.write().await = Some(paused); - let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); - let mut events = app_state.event_manager.get_event_channel(); - - let response = cancel_download( - axum::extract::State(app_state), - axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), - ) - .await - .into_response(); - - assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); - assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); - assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - assert!(downloads.finished.read().await.is_empty()); - let active = downloads.active.read().await; - assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); - assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); - } - - #[tokio::test] - async fn cancel_normalizes_active_and_promotes_next_in_one_commit() { - let dir = tempfile::tempdir().expect("tempdir"); - let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); - let mut active = make_download(DownloadKind::Download, DownloadState::Paused, None, None); - active.paused = true; - active.next_retry_at = Some(42); - active.error = None; - *queue.active.write().await = Some(active); - let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); - next.uuid = "next".to_string(); - queue.queue.lock().await.push_back(next); - - let committed = cancel_active_and_promote(&queue, "id").await.expect("cancel commit"); - - assert!(committed); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); - let finished = queue.finished.read().await; - let cancelled = finished.first().expect("cancelled task"); - assert!(cancelled.finished); - assert!(!cancelled.paused); - assert_eq!(cancelled.state, DownloadState::Cancelled); - assert_eq!(cancelled.error.as_deref(), Some("Cancelled by user")); - assert!(cancelled.next_retry_at.is_none()); - assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("next")); - } - - #[tokio::test] - async fn cancel_uuid_mismatch_does_not_finish_or_promote_next_task() { - let queue = DownloadQueue::new(); - *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Paused, None, None)); - let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); - next.uuid = "next".to_string(); - queue.queue.lock().await.push_back(next); - - let committed = cancel_active_and_promote(&queue, "next").await.expect("cancel no-op"); - - assert!(!committed); - assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); - assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("id")); - assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("next")); - assert!(queue.finished.read().await.is_empty()); - } - - #[tokio::test] - async fn worker_noop_mutation_does_not_broadcast() { - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let queue = DownloadQueue::new(); - - let changed = broadcast_worker_mutation(&event_manager, &queue, Ok(false), "test no-op mutation").await; - - assert!(!changed.expect("no-op result")); - assert!(events.try_recv().is_err()); - } - - #[tokio::test] - async fn worker_commit_error_is_propagated_without_clearing_control() { - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let queue = DownloadQueue::new(); - *queue.control_signal.write().await = DownloadControl::Cancel; - - let result = broadcast_worker_mutation( - &event_manager, - &queue, - Err(crate::api::model::QueueMutationError::DiskFull), - "terminal transition", - ) - .await; - - assert!(result.is_err()); - assert_eq!(*queue.control_signal.read().await, DownloadControl::Cancel); - assert!(events.try_recv().is_err()); - } - - #[tokio::test] - async fn required_worker_noop_is_an_error_without_broadcast() { - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let queue = DownloadQueue::new(); - - let result = broadcast_required_worker_mutation(&event_manager, &queue, Ok(false), "terminal transition").await; - - assert!(result.is_err()); - assert!(events.try_recv().is_err()); - } - - #[tokio::test] - async fn pause_and_resume_handlers_return_without_hanging() { - let app_state = create_test_app_state(); - let active = FileDownload { - uuid: "handler-id".to_string(), - file_dir: PathBuf::from("/tmp"), - file_path: PathBuf::from("/tmp/handler-file.bin"), - filename: "handler-file.bin".to_string(), - url: reqwest::Url::parse("https://example.com/file.bin").expect("valid url"), - finished: false, - size: 32, - total_size: Some(64), - paused: false, - error: None, - state: DownloadState::Downloading, - start_at: None, - duration_secs: None, - kind: DownloadKind::Download, - input_name: None, - priority: 0, - retry_attempts: 0, - next_retry_at: None, - recording: None, - }; - *app_state.downloads.active.write().await = Some(active); - - let pause_response = tokio::time::timeout( - Duration::from_millis(100), - pause_download( - axum::extract::State(Arc::clone(&app_state)), - axum::extract::Json(DownloadActionRequest { uuid: "handler-id".to_string() }), - ), - ) - .await; - assert!(pause_response.is_ok(), "pause handler should return promptly"); - - let resume_response = tokio::time::timeout( - Duration::from_millis(100), - resume_download( - axum::extract::State(app_state), - axum::extract::Json(DownloadActionRequest { uuid: "handler-id".to_string() }), - ), - ) - .await; - assert!(resume_response.is_ok(), "resume handler should return promptly"); - - let _ = pause_response.expect("pause response").into_response(); - let _ = resume_response.expect("resume response").into_response(); - } - - #[tokio::test] - async fn queue_update_notifies_recording_subscribers() { - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let queue = DownloadQueue::new(); - - broadcast_download_queue_update(&event_manager, &queue).await; - - let mut recording_changed = false; - while let Ok(event) = events.try_recv() { - if event == EventMessage::RecordingChanged { - recording_changed = true; - } - } - assert!(recording_changed); - } -} +mod tests; diff --git a/backend/app/src/api/endpoints/download_api/tests.rs b/backend/app/src/api/endpoints/download_api/tests.rs new file mode 100644 index 000000000..5150ae15c --- /dev/null +++ b/backend/app/src/api/endpoints/download_api/tests.rs @@ -0,0 +1,842 @@ +use super::{ + active_download_snapshot, active_download_snapshot_for_worker, broadcast_download_queue_update, + broadcast_required_worker_mutation, broadcast_worker_mutation, cancel_active_and_promote, cancel_download, + commit_acquired_download, finish_active_and_promote, mark_recording_notification, parse_content_range_total, + pause_download, preemption_reason_for, recording_deadline_reached, recording_execution_download, + refresh_recording_progress, requeue_active_download_for_capacity_wait, requeue_active_download_for_retry, + resume_download, retryable_transport_error_message, rollback_last_recording_marker, set_active_download_state, + should_exit_worker_after_preempt, DownloadActionRequest, DOWNLOAD_PREEMPTED_REASON, RECORDING_PREEMPTED_REASON, +}; +use crate::{ + api::model::{ + recording_notification::LifecycleEvent, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, + ConnectionManager, DownloadControl, DownloadKind, DownloadQueue, DownloadState, EventManager, EventMessage, + FileDownload, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard, + }, + model::{ + ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, MediaToolCapabilities, MessageContent, + ProcessTargets, SourcesConfig, + }, + repository::GeoIp, + utils::FileLockManager, +}; +use arc_swap::{ArcSwap, ArcSwapOption}; +use axum::response::IntoResponse; +use reqwest::header::{HeaderMap, HeaderValue}; +use shared::{ + model::{ + ConfigPaths, InputFetchMethod, InputType, RecordingMetadata, RecordingOwner, RecordingSource, + RecordingVisibility, UserId, + }, + utils::Internable, +}; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::{mpsc, RwLock}; + +fn make_download( + kind: DownloadKind, + state: DownloadState, + start_at: Option, + duration_secs: Option, +) -> FileDownload { + FileDownload { + uuid: "id".to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from("/tmp/file.ts"), + filename: "file.ts".to_string(), + url: reqwest::Url::parse("https://example.com/file.ts").expect("valid url"), + finished: false, + size: 128, + total_size: Some(1024), + paused: false, + error: Some("transient".to_string()), + state, + start_at, + duration_secs, + kind, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: None, + } +} + +fn attach_recording(download: &mut FileDownload, owner: RecordingOwner, visibility: RecordingVisibility) { + let mut metadata = + RecordingMetadata::new(owner, visibility, RecordingSource::new("1", "42", "input-a"), 1_000, 1_060, 0, 0); + metadata.program_title = Some("Programme".to_string()); + metadata.channel_name = Some("Channel".to_string()); + metadata.relative_path = Some("Channel/Programme.ts".to_string()); + download.recording = Some(metadata); +} + +#[test] +fn recording_notification_marker_is_at_most_once() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + + let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + let duplicate = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + + assert!(matches!(first.message, Some(MessageContent::RecordingLifecycle(_)))); + assert!(duplicate.message.is_none()); + assert_eq!(download.recording.as_ref().map_or(0, |metadata| metadata.notification_markers.len()), 1); +} + +#[test] +fn private_user_recording_notification_is_suppressed() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording(&mut download, RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Private); + + let message = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + + assert!(message.message.is_none()); + assert_eq!(download.recording.as_ref().map_or(0, |metadata| metadata.notification_markers.len()), 0); +} + +#[test] +fn rollback_last_recording_marker_removes_most_recent_matching_kind() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + + // Two distinct marker kinds end up in the same metadata after a + // successful Completed followed by a Failed on the same task — this + // mirrors what would happen in production across two persist rounds. + let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + let second = mark_recording_notification(&mut download, LifecycleEvent::Failed, Some("ffmpeg exited".to_string())); + assert!(first.marker_kind.is_some()); + assert!(second.marker_kind.is_some()); + + let kind = second.marker_kind.unwrap(); + rollback_last_recording_marker(&mut download, &kind); + + let markers = &download.recording.as_ref().unwrap().notification_markers; + assert_eq!(markers.len(), 1, "only the Completed marker should remain"); + assert!(matches!(markers[0].kind, shared::model::recording::NotificationMarkerKind::Completed)); +} + +#[test] +fn recording_deadline_uses_start_plus_duration() { + let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + let normal = make_download(DownloadKind::Download, DownloadState::Downloading, Some(1_000), Some(60)); + + assert!(!recording_deadline_reached(&recording, 1_059)); + assert!(recording_deadline_reached(&recording, 1_060)); + assert!(!recording_deadline_reached(&normal, 1_060)); +} + +#[test] +fn recording_execution_requires_metadata_source() { + let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + + let result = recording_execution_download(&create_test_app_config(), &recording); + + assert_eq!(result.as_ref().err().map(String::as_str), Some("Recording source metadata missing")); +} + +#[test] +fn recording_execution_uses_fresh_token_without_mutating_persisted_descriptor() { + let mut recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + recording.url = reqwest::Url::parse( + "tuliprox-recording://source?target_name=stable-target&input_name=provider_1&virtual_id=42&cluster=live", + ) + .expect("valid descriptor"); + attach_recording(&mut recording, RecordingOwner::LegacyAdmin, RecordingVisibility::Private); + let source = recording.recording.as_mut().and_then(|metadata| metadata.source.as_mut()).expect("recording source"); + source.target_id = "stable-target".to_string(); + source.virtual_id = "42".to_string(); + source.input_name = "provider_1".to_string(); + let persisted_before = DownloadQueue::to_persisted(&recording); + let app_config = create_test_app_config(); + + let execution = recording_execution_download(&app_config, &recording).expect("execution download"); + let token = execution + .url + .path_segments() + .and_then(|segments| segments.collect::>().get(4).copied()) + .expect("route token"); + + assert!(crate::auth::verify_access_token( + token, + &app_config.access_token_secret, + crate::auth::scope::INTERNAL_PLAYER + )); + assert_eq!(recording.url.as_str(), persisted_before.url); + assert_eq!(DownloadQueue::to_persisted(&recording).url, persisted_before.url); + assert_ne!(execution.url, recording.url); +} + +#[tokio::test] +async fn retry_requeues_active_download_at_front_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + let queued = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + let active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + + queue.queue.lock().await.push_back(queued); + *queue.active.write().await = Some(active); + + requeue_active_download_for_retry(&queue, "id", false).await.expect("requeue retry"); + + assert!(queue.active.read().await.is_none()); + let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); + assert_eq!(queued_items.len(), 2); + assert_eq!(queued_items[0].state, DownloadState::Queued); + assert_eq!(queued_items[0].size, 128); + assert!(queued_items[0].error.is_none()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + let persisted: crate::api::model::PersistedDownloadQueue = + serde_json::from_slice(&std::fs::read(state_file).expect("read state")).expect("parse state"); + assert_eq!(persisted.revision, shared::model::QueueRevision(1)); +} + +#[tokio::test] +async fn preempted_active_download_requeues_to_capacity_wait_with_partial_progress() { + let queue = DownloadQueue::new(); + let mut active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + active.size = 512; + active.total_size = Some(2048); + *queue.active.write().await = Some(active); + + requeue_active_download_for_capacity_wait(&queue, "id", DOWNLOAD_PREEMPTED_REASON, false, None) + .await + .expect("requeue capacity wait"); + + assert!(queue.active.read().await.is_none()); + let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); + assert_eq!(queued_items.len(), 1); + assert_eq!(queued_items[0].state, DownloadState::WaitingForCapacity); + assert_eq!(queued_items[0].size, 512); + assert_eq!(queued_items[0].total_size, Some(2048)); + assert_eq!(queued_items[0].error.as_deref(), Some(DOWNLOAD_PREEMPTED_REASON)); +} + +#[tokio::test] +async fn terminal_transition_finishes_active_and_promotes_next_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); + *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + finish_active_and_promote(&queue, "id", |finished| { + finished.finished = true; + finished.state = DownloadState::Completed; + super::RecordingNotificationPlan::empty() + }) + .await + .expect("terminal commit"); + + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(queue.finished.read().await.len(), 1); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("next")); +} + +#[tokio::test] +async fn worker_mutation_failure_keeps_memory_and_revision_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let queue = DownloadQueue::new_with_state_file(Some(blocking_dir)); + *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); + + let result = requeue_active_download_for_retry(&queue, "id", false).await; + + assert!(result.is_err()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(queue.queue.lock().await.is_empty()); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.state.clone()), Some(DownloadState::Downloading)); +} + +#[tokio::test] +async fn preempted_active_recording_requeues_with_recording_specific_policy_message() { + let queue = DownloadQueue::new(); + let mut active = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(600)); + active.size = 512; + *queue.active.write().await = Some(active); + + requeue_active_download_for_capacity_wait(&queue, "id", RECORDING_PREEMPTED_REASON, false, None) + .await + .expect("requeue recording"); + + let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); + assert_eq!(queued_items.len(), 1); + assert_eq!(queued_items[0].kind, DownloadKind::Recording); + assert_eq!(queued_items[0].state, DownloadState::WaitingForCapacity); + assert_eq!(queued_items[0].error.as_deref(), Some(RECORDING_PREEMPTED_REASON)); +} + +#[test] +fn preemption_reason_is_explicit_for_recordings_and_downloads() { + let download = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + + assert_eq!(preemption_reason_for(&download), DOWNLOAD_PREEMPTED_REASON); + assert_eq!(preemption_reason_for(&recording), RECORDING_PREEMPTED_REASON); +} + +#[test] +fn only_restart_exits_worker_after_preempt() { + assert!(!should_exit_worker_after_preempt(DownloadControl::None)); + assert!(!should_exit_worker_after_preempt(DownloadControl::Pause)); + assert!(!should_exit_worker_after_preempt(DownloadControl::Cancel)); + assert!(should_exit_worker_after_preempt(DownloadControl::Restart)); +} + +#[tokio::test] +async fn set_active_download_state_updates_snapshot_state() { + let queue = DownloadQueue::new(); + let active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + *queue.active.write().await = Some(active); + + let changed = + set_active_download_state(&queue, "id", DownloadState::WaitingForCapacity, Some("waiting".to_string()), false) + .await; + + assert!(changed.expect("set active state")); + let active = queue.active.read().await.clone().expect("active download"); + assert_eq!(active.state, DownloadState::WaitingForCapacity); + assert_eq!(active.error.as_deref(), Some("waiting")); + assert!(!active.paused); +} + +#[tokio::test] +async fn acquisition_without_provider_handle_commits_downloading_state() { + let queue = DownloadQueue::new(); + *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Queued, None, None)); + + let notification = commit_acquired_download(&queue, "id").await.expect("acquired commit"); + + assert!(notification.is_some()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.state.clone()), Some(DownloadState::Downloading)); +} + +#[tokio::test] +async fn acquired_transition_rejects_switched_active_task() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Recording, DownloadState::Queued, None, None); + switched.uuid = "task-b".to_string(); + attach_recording(&mut switched, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + *queue.active.write().await = Some(switched); + + let transition = commit_acquired_download(&queue, "task-a").await.map(|notification| notification.is_some()); + let result = + broadcast_required_worker_mutation(&event_manager, &queue, transition, "acquired downloading state").await; + + assert!(result.is_err()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("task-b")); + assert_eq!( + queue + .active + .read() + .await + .as_ref() + .and_then(|active| active.recording.as_ref()) + .map_or(0, |recording| recording.notification_markers.len()), + 0 + ); + assert!(events.try_recv().is_err()); +} + +#[tokio::test] +async fn post_acquire_snapshot_rejects_switched_active_task() { + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + switched.uuid = "task-b".to_string(); + *queue.active.write().await = Some(switched); + + assert!(active_download_snapshot_for_worker(&queue.active, "task-a").await.is_none()); +} + +#[tokio::test] +async fn stale_worker_progress_does_not_update_switched_active_task() { + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Recording, DownloadState::Downloading, None, None); + switched.uuid = "task-b".to_string(); + switched.size = 10; + *queue.active.write().await = Some(switched); + let dir = tempfile::tempdir().expect("tempdir"); + let progress_path = dir.path().join("task-a.ts.part"); + std::fs::write(&progress_path, [0_u8; 20]).expect("progress file"); + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + + refresh_recording_progress(&queue.active, "task-a", &progress_path, &event_manager).await; + + assert_eq!(queue.active.read().await.as_ref().map(|active| active.size), Some(10)); + assert!(events.try_recv().is_err()); +} + +#[test] +fn compute_download_retry_backoff_uses_multiplier_and_cap() { + let download_cfg = crate::model::VideoDownloadConfig { + headers: std::collections::HashMap::new(), + directory: "/tmp".to_string(), + organize_into_directories: false, + episode_pattern: None, + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 0, + retry_backoff_initial_secs: 3, + retry_backoff_multiplier: 3.0, + retry_backoff_max_secs: 30, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 5, + recording: None, + }; + + assert_eq!(super::compute_download_retry_backoff_secs(1, &download_cfg), 3); + assert_eq!(super::compute_download_retry_backoff_secs(2, &download_cfg), 9); + assert_eq!(super::compute_download_retry_backoff_secs(3, &download_cfg), 27); + assert_eq!(super::compute_download_retry_backoff_secs(8, &download_cfg), 30); +} + +#[test] +fn background_download_waits_when_all_candidates_hit_background_limit() { + let download_cfg = crate::model::VideoDownloadConfig { + headers: std::collections::HashMap::new(), + directory: "/tmp".to_string(), + organize_into_directories: false, + episode_pattern: None, + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 2, + retry_backoff_initial_secs: 3, + retry_backoff_multiplier: 3.0, + retry_backoff_max_secs: 30, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 5, + recording: None, + }; + + let capacities = vec![(Arc::::from("a"), 2, 5), (Arc::::from("b"), 3, 5)]; + assert!(super::background_download_should_wait(1, &capacities, &download_cfg)); + assert!(!super::background_download_should_wait(0, &capacities, &download_cfg)); +} + +#[test] +fn background_download_waits_when_reserved_user_slots_would_be_consumed() { + let download_cfg = crate::model::VideoDownloadConfig { + headers: std::collections::HashMap::new(), + directory: "/tmp".to_string(), + organize_into_directories: false, + episode_pattern: None, + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 1, + max_background_per_provider: 0, + retry_backoff_initial_secs: 3, + retry_backoff_multiplier: 3.0, + retry_backoff_max_secs: 30, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 5, + recording: None, + }; + + let blocked = vec![(Arc::::from("a"), 4, 5), (Arc::::from("b"), 4, 5)]; + let allowed = vec![(Arc::::from("a"), 3, 5), (Arc::::from("b"), 4, 6)]; + assert!(super::background_download_should_wait(1, &blocked, &download_cfg)); + assert!(!super::background_download_should_wait(1, &allowed, &download_cfg)); +} + +#[test] +fn retryable_transport_error_message_detects_common_transient_failures() { + assert!(retryable_transport_error_message("dns lookup failed")); + assert!(retryable_transport_error_message("connection reset by peer")); + assert!(retryable_transport_error_message("operation timed out")); + assert!(!retryable_transport_error_message("invalid URL")); +} + +#[tokio::test] +async fn active_download_snapshot_releases_read_lock_before_followup_write() { + let active = Arc::new(RwLock::new(Some(FileDownload { + uuid: "id".to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from("/tmp/file.bin"), + filename: "deadlock-test.bin".to_string(), + url: reqwest::Url::parse("https://example.com/file.bin").expect("valid url"), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Downloading, + start_at: None, + duration_secs: None, + kind: DownloadKind::Download, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: None, + }))); + let snapshot = active_download_snapshot(&active).await; + assert!(snapshot.is_some()); + + let write_result = tokio::time::timeout(Duration::from_millis(100), active.write()).await; + assert!(write_result.is_ok(), "write lock should not be blocked by snapshot helper"); +} + +#[test] +fn parse_content_range_total_extracts_full_size() { + let mut headers = HeaderMap::new(); + headers.insert("content-range", HeaderValue::from_static("bytes 512-1023/4096")); + + assert_eq!(parse_content_range_total(&headers), Some(4096)); +} + +fn create_test_app_config() -> AppConfig { + let input = Arc::new(ConfigInput { + id: 1, + name: "provider_1".intern(), + input_type: InputType::Xtream, + headers: HashMap::default(), + url: "http://provider-1.example".to_string(), + username: Some("user1".to_string()), + password: Some("pass1".to_string()), + enabled: true, + priority: 0, + max_connections: 1, + method: InputFetchMethod::default(), + aliases: None, + ..ConfigInput::default() + }); + let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::from(Some(Arc::new(ApiProxyConfig { + server: vec![ApiProxyServerInfo { + name: "default".to_string(), + protocol: "http".to_string(), + host: "player.example".to_string(), + port: None, + timezone: "UTC".to_string(), + message: String::new(), + path: None, + }], + ..ApiProxyConfig::default() + })))), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + } +} + +fn create_test_app_state_with_downloads(downloads: Arc) -> Arc { + let app_cfg = Arc::new(create_test_app_config()); + let event_manager = Arc::new(EventManager::new()); + let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); + let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); + active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); + + let geoip = Arc::new(ArcSwapOption::::default()); + let config = app_cfg.config.load(); + let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + let connection_manager = + Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager, None)); + + let tokens = CancelTokens::default(); + let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); + let (manual_update_sender, _) = mpsc::channel::(1); + + Arc::new(AppState { + forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { + enabled: false, + inputs: Vec::new(), + targets: Vec::new(), + target_names: Vec::new(), + })), + app_config: app_cfg, + http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + downloads, + cache: Arc::new(ArcSwapOption::default()), + shared_stream_manager, + hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), + hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), + active_users, + active_provider, + connection_manager, + event_manager, + cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)), + playlists: Arc::new(PlaylistStorageState::new()), + geoip, + update_guard: UpdateGuard::new(), + metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), + manual_update_sender, + }) +} + +fn create_test_app_state() -> Arc { create_test_app_state_with_downloads(Arc::new(DownloadQueue::new())) } + +#[tokio::test] +async fn pause_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + *downloads.active.write().await = + Some(make_download(DownloadKind::Download, DownloadState::Downloading, None, None)); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = pause_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Downloading)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(false)); +} + +#[tokio::test] +async fn resume_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + paused.paused = true; + *downloads.active.write().await = Some(paused); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = resume_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); +} + +#[tokio::test] +async fn paused_cancel_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + paused.paused = true; + *downloads.active.write().await = Some(paused); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = cancel_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(downloads.finished.read().await.is_empty()); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); +} + +#[tokio::test] +async fn cancel_normalizes_active_and_promotes_next_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); + let mut active = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + active.paused = true; + active.next_retry_at = Some(42); + active.error = None; + *queue.active.write().await = Some(active); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + let committed = cancel_active_and_promote(&queue, "id").await.expect("cancel commit"); + + assert!(committed); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + let finished = queue.finished.read().await; + let cancelled = finished.first().expect("cancelled task"); + assert!(cancelled.finished); + assert!(!cancelled.paused); + assert_eq!(cancelled.state, DownloadState::Cancelled); + assert_eq!(cancelled.error.as_deref(), Some("Cancelled by user")); + assert!(cancelled.next_retry_at.is_none()); + assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("next")); +} + +#[tokio::test] +async fn cancel_uuid_mismatch_does_not_finish_or_promote_next_task() { + let queue = DownloadQueue::new(); + *queue.active.write().await = Some(make_download(DownloadKind::Download, DownloadState::Paused, None, None)); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + let committed = cancel_active_and_promote(&queue, "next").await.expect("cancel no-op"); + + assert!(!committed); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("id")); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("next")); + assert!(queue.finished.read().await.is_empty()); +} + +#[tokio::test] +async fn worker_noop_mutation_does_not_broadcast() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + let changed = broadcast_worker_mutation(&event_manager, &queue, Ok(false), "test no-op mutation").await; + + assert!(!changed.expect("no-op result")); + assert!(events.try_recv().is_err()); +} + +#[tokio::test] +async fn worker_commit_error_is_propagated_without_clearing_control() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + *queue.control_signal.write().await = DownloadControl::Cancel; + + let result = broadcast_worker_mutation( + &event_manager, + &queue, + Err(crate::api::model::QueueMutationError::DiskFull), + "terminal transition", + ) + .await; + + assert!(result.is_err()); + assert_eq!(*queue.control_signal.read().await, DownloadControl::Cancel); + assert!(events.try_recv().is_err()); +} + +#[tokio::test] +async fn required_worker_noop_is_an_error_without_broadcast() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + let result = broadcast_required_worker_mutation(&event_manager, &queue, Ok(false), "terminal transition").await; + + assert!(result.is_err()); + assert!(events.try_recv().is_err()); +} + +#[tokio::test] +async fn pause_and_resume_handlers_return_without_hanging() { + let app_state = create_test_app_state(); + let active = FileDownload { + uuid: "handler-id".to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from("/tmp/handler-file.bin"), + filename: "handler-file.bin".to_string(), + url: reqwest::Url::parse("https://example.com/file.bin").expect("valid url"), + finished: false, + size: 32, + total_size: Some(64), + paused: false, + error: None, + state: DownloadState::Downloading, + start_at: None, + duration_secs: None, + kind: DownloadKind::Download, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: None, + }; + *app_state.downloads.active.write().await = Some(active); + + let pause_response = tokio::time::timeout( + Duration::from_millis(100), + pause_download( + axum::extract::State(Arc::clone(&app_state)), + axum::extract::Json(DownloadActionRequest { uuid: "handler-id".to_string() }), + ), + ) + .await; + assert!(pause_response.is_ok(), "pause handler should return promptly"); + + let resume_response = tokio::time::timeout( + Duration::from_millis(100), + resume_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "handler-id".to_string() }), + ), + ) + .await; + assert!(resume_response.is_ok(), "resume handler should return promptly"); + + let _ = pause_response.expect("pause response").into_response(); + let _ = resume_response.expect("resume response").into_response(); +} + +#[tokio::test] +async fn queue_update_notifies_recording_subscribers() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + broadcast_download_queue_update(&event_manager, &queue).await; + + let mut recording_changed = false; + while let Ok(event) = events.try_recv() { + if event == EventMessage::RecordingChanged { + recording_changed = true; + } + } + assert!(recording_changed); +} diff --git a/backend/app/src/api/endpoints/hls_api.rs b/backend/app/src/api/endpoints/hls_api.rs deleted file mode 100644 index dbdb52460..000000000 --- a/backend/app/src/api/endpoints/hls_api.rs +++ /dev/null @@ -1,20379 +0,0 @@ -#![allow(clippy::large_futures)] - -// Cross-import of xtream URL helpers is routed through `xtream_url` (a one-way -// re-export module) so this endpoint file no longer depends on a sibling -// endpoint file directly. See `xtream_url`'s module docs for the ARCH-1 -// roadmap that motivates this re-export. -use super::{ - hls_terminal_response::{ - hls_manifest_terminal_preflight, hls_response, hls_temporary_resource_unavailable_response, - hls_terminal_failed_closed_response, hls_terminal_playback_response, resolve_hls_terminal_manifest_state, - terminal_segment_get_response, terminal_segment_head_response, terminal_segment_immutable_replay_response, - terminal_tail_plan_for_current_route, HlsManifestTerminalPreflight, - }, - xtream_url::{get_query_path, get_xtream_player_api_stream_url, ApiStreamContext}, -}; -use crate::{ - api::{ - api_utils::{ - connection_priority_for_kind, create_api_proxy_user, create_m3u_catchup_session_key, - create_playback_session_fingerprint, create_session_fingerprint, force_provider_stream_response, - get_headers_from_request, get_hls_session_ttl_secs, get_stream_alternative_url, - is_hls_stream_share_enabled, is_seekable_media_request, local_stream_response, - record_connect_failed_attempt, resolve_playback_request_admission, try_option_bad_request, try_unwrap_body, - ConnectFailedAttempt, EvictionReentryGuard, HeaderFilter, - }, - model::{ - hls_cache::initial_strip::{ - materialize_initial_hls_strip_view, HlsInitialStripOutcome, HlsInitialStripSkipReason, - }, - hls_custom_video_manifest_response_for_access_lease, hls_custom_video_manifest_response_with_virtual_id, - hls_provisioning_discontinuity_sequence, hls_virtual_entry_redirect_response, - is_custom_video_stream_enabled, start_hls_panel_provisioning_once, - try_hls_panel_provisioning_manifest_response, AppState, ConnectionHistoryMode, CustomVideoStreamType, - GraceMode, HlsPanelProvisioningRedirectPaths, HlsProvisioningStatus, ProviderAllocation, - ProviderConfig as RuntimeProviderConfig, ProviderHandle, StreamMeterHandle, TransportStreamBuffer, - UserSession, - }, - panel_api::can_provision_on_exhausted, - }, - auth::{check_network_access_only, Fingerprint}, - model::{ - ConfigInput, ConfigInputFlags, ConfigProvider, ConfigTarget, InputSource, ProxyUserCredentials, - ReverseProxyDisabledHeaderConfig, - }, - processing::parser::hls::{ - get_hls_session_token_and_url_from_token, origin_manifest::HlsManifestWindowPolicy, rewrite_hls, - RewriteHlsProps, - }, - repository::{ - load_input_live_bitrate_bps, m3u_get_item_for_stream_id, persist_input_live_bitrate_bps, storage_const, - xtream_get_item_for_stream_id, LiveBitratePersistenceOutcome, - }, - utils::{content_coding::OutboundContentCodingPolicy, debug_if_enabled, request, request::is_file_url}, -}; -use axum::{ - body::Body, - http::{header, HeaderMap, HeaderValue, Method, StatusCode}, - response::IntoResponse, -}; -use futures::FutureExt; -use log::{debug, error, warn}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use shared::{ - defaults::HLS_EXT, - model::{ - ConnectFailureReason, FailureStage, InputType, PlaylistEntry, PlaylistItemType, StreamChannel, StreamInfo, - StreamProperties, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, - }, - utils::{ - extract_extension_from_url, generate_random_string, is_hls_url, is_m3u_catchup_session_token, - replace_url_extension, sanitize_sensitive_info, Internable, PROVIDER_SCHEME_PREFIX, - }, -}; -use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; -use tuliprox_hls::{ - api::{ - begin_hls_origin_account_io_bounded, build_hls_origin_session_owner, build_proxy_session_id, - cold_start_retry_after_seconds, commit_hls_runtime_custom_tail, derive_hls_lease_manifest_snapshot, - extract_hls_provider_session_headers, fetch_and_commit_hls_transient_origin_response_with_attempt_prepare, - fetch_hls_transient_origin_response_with_attempt_prepare, finite_hls_terminal_key_response, - force_identity_without_range, hls_cached_manifest_options_for_requirement, - hls_committed_manifest_body_for_request, hls_manifest_acceptance_directive_for_session, - hls_manifest_commit_requirement, hls_object_body_deadline, hls_origin_account_status, - hls_should_wait_for_initial_manifest_commit, hls_startup_admission_allows_snapshot, - hls_transient_object_fetch_failure, hls_transient_origin_response, is_hls_provisioning_gap_segment, - is_hls_provisioning_segment, maybe_trigger_origin_refresh_with_outcome, new_hls_access_lease_id, - origin_account_binding_from_allocation, record_successful_transient_segment_fetch, - record_temporary_transient_segment_fetch_failure, register_hls_availability_reevaluation, - resolve_hls_transient_object_cache_action, safe_hls_access_lease_id, safe_proxy_session_id, safe_session_key, - safe_user_session_token, scrub_hls_origin_headers, serve_hls_map_cache_outcome, - serve_hls_segment_cache_outcome, serve_hls_transient_object_cache_outcome, - serve_hls_transient_object_cache_response, should_remove_hls_origin_header, trigger_origin_refresh_sync, - validate_hls_access_lease, CacheAccessState, HlsAccessAdmissionMode, HlsAccessContext, HlsAccessLease, - HlsAccessLeaseActivation, HlsAccessLeaseId, HlsAccessLeasePendingDeadline, HlsAccessLeaseState, - HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsAccessLeaseValidationError, HlsAccountBindingProtection, - HlsAccountOverlapTiming, HlsAvailabilityReevaluationObservation, HlsAvailabilityReevaluationRegistration, - HlsBandwidthPersistenceOutcome, HlsBoundAccountAcquireErrorKind, HlsCacheResponseContext, - HlsCachedManifestOptions, HlsCommittedManifestBody, HlsEffectiveOriginAcquirePolicy, HlsLeaseManifestSnapshot, - HlsLeaseManifestSnapshotInput, HlsLeaseManifestUriMaterialization, HlsLeasePlaybackMode, - HlsLeaseStartupAdmissionState, HlsLogIdentity, HlsManifestAcceptanceDirective, - HlsManifestAcceptanceEvaluationOutcome, HlsManifestCommitIdentity, HlsManifestCommitRequirement, - HlsManifestLimitViolation, HlsMapFile, HlsMasterBandwidth, HlsMasterBandwidthSelection, - HlsMediaActivityCommitOutcome, HlsMediaActivityMarker, HlsMediaLeaseIdentity, HlsOriginAccountBinding, - HlsOriginAccountBindingMode, HlsOriginAccountDetachedReason, HlsOriginAccountStatus, HlsOriginIoContext, - HlsOriginRefreshTriggerOutcome, HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginSource, - HlsOriginSourceKind, HlsOriginWorkClass, HlsPlaybackFamilyKey, HlsPostRefreshRuntime, - HlsPublishedTransientResourceIds, HlsQosMeterInit, HlsQosRuntimeConfig, HlsResourceFetchAttempt, - HlsResourceServeFailure, HlsResourceServeOutcome, HlsRuntimeCustomTailOutcome, HlsRuntimeCustomTailReason, - HlsRuntimeCustomTailRequest, HlsSegmentFile, HlsSession, HlsSessionHandle, HlsSessionKey, HlsSessionMode, - HlsSessionStoreOutcome, HlsSingleVariantMasterPlaylist, HlsTerminalFailedClosedReason, HlsTerminalSegmentPath, - HlsTransientCacheCommitContext, HlsTransientDecodedOriginResponse, HlsTransientDirectResponseContext, - HlsTransientManifestTemplate, HlsTransientObjectCacheAction, HlsTransientObjectFetchFailure, - HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest, HlsTransientOriginFetchRequest, - HlsTransientOriginIoGuard, HlsTransientResourceLeaseContext, LiveHlsOriginEntry, OriginRefreshRequest, - OriginSegmentKey, ProxySessionId, RetryPolicy, SegmentCacheKey, SegmentCacheStatus, SegmentDemandFetchOutcome, - SegmentEntry, SegmentFetchContext, SegmentFetchPolicy, TransientManifestGeneration, TransientObjectFetchToken, - TransientObjectUnavailableState, TransientPassthroughState, TransientResourceFile, TransientResourceId, - TransientResourceRef, HLS_ACCESS_LEASE_ID_PLACEHOLDER, HLS_PROVISIONING_GAP_ORIGIN_EPOCH, - HLS_PROVISIONING_ORIGIN_EPOCH, HLS_PROVISIONING_SEGMENT_DURATION_MS, HLS_PROVISIONING_TARGET_DURATION_SECS, - MAX_HLS_MANIFEST_BYTES, - }, - HlsCtx, MAX_MANUAL_REDIRECTS, -}; -use url::Url; - -const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS: u64 = 1; -const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS: u64 = HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS * 1_000; -/// Poll interval while waiting for a canonical manifest commit. Lower values -/// reduce time-to-first-manifest at the cost of more wakeups per waiting client. -const HLS_MANIFEST_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Recover archive EPG reference from `m3u-catchup|...|archive|{start}|{duration}` session keys. -/// -/// `BitTV` archive media URLs look like `2026/07/24/14/13/38-06800.ts` and lose Flussonic -/// path markers after HLS rewrite, so the panel would otherwise keep showing Live + live EPG. -pub(in crate::api) fn m3u_catchup_epg_reference_from_session_token(session_token: &str) -> Option { - let rest = session_token.strip_prefix("m3u-catchup|")?; - for marker in ["|archive|", "|timeshift_abs|"] { - if let Some(idx) = rest.rfind(marker) { - let after = &rest[idx + marker.len()..]; - let start = after.split('|').next()?.trim(); - if let Ok(ts) = start.parse::() { - return Some(ts); - } - } - } - None -} - -fn resolve_m3u_archive_reference(stream_url: &str, session_token: Option<&str>) -> Option { - m3u_archive_epg_reference_ts(stream_url) - .or_else(|| epg_reference_ts_from_date_tree_path(stream_url)) - .or_else(|| session_token.and_then(m3u_catchup_epg_reference_from_session_token)) -} - -fn looks_like_archive_media_path(path: &str) -> bool { - let rel = path.trim_start_matches('/'); - if rel.is_empty() { - return false; - } - rel.starts_with("dvr-") || rel.contains("/dvr-") || epg_reference_ts_from_date_tree_path(rel).is_some() -} - -/// `BitTV` / Flussonic date-tree segments: `YYYY/MM/DD/HH/MM/SS-*.ts` or `dvr-YYYY/...`. -pub(in crate::api) fn epg_reference_ts_from_date_tree_path(path: &str) -> Option { - let owned_path; - let mut rel = path.trim_start_matches('/'); - if let Some(idx) = rel.find('?') { - rel = &rel[..idx]; - } - if rel.contains("://") { - let parsed = Url::parse(rel).ok()?; - owned_path = parsed.path().trim_start_matches('/').to_string(); - rel = owned_path.as_str(); - } - if let Some(rest) = rel.strip_prefix("dvr-") { - rel = rest; - } - let mut parts = rel.split('/'); - let year: i32 = parts.next()?.parse().ok()?; - if !(2000..=2100).contains(&year) { - return None; - } - let month: u32 = parts.next()?.parse().ok()?; - let day: u32 = parts.next()?.parse().ok()?; - let hour: u32 = parts.next()?.parse().ok()?; - let minute: u32 = parts.next()?.parse().ok()?; - let sec_token = parts.next()?.split('-').next()?.trim_end_matches(".ts").trim_end_matches(".m3u8"); - let second: u32 = sec_token.parse().ok()?; - let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, second)?; - Some(naive.and_utc().timestamp()) -} - -/// Join a client-leaked relative DVR/media path against the session's origin URL. -/// -/// When an origin `.m3u8` is force-piped without `rewrite_hls`, players resolve -/// `dvr-2026/...ts?token=` against the proxy playlist URL (`/hls/.../{token}.m3u8`). -fn resolve_leaked_hls_relative_origin( - session_stream_url: &str, - relative_path: &str, - request_query: Option<&str>, -) -> Option { - let rel = relative_path.trim_start_matches('/'); - if rel.is_empty() || rel.contains("://") { - return None; - } - // Only recover archive-style relative paths (BitTV/Flussonic DVR or date trees). - if !looks_like_archive_media_path(rel) { - return None; - } - let parsed = url::Url::parse(session_stream_url).ok()?; - let session_path = parsed.path(); - - // If the session URL is already inside a DVR/date tree, strip back to the stream root - // so sibling relative segments do not nest under the previous segment directory. - let joined = if rel.starts_with("dvr-") { - if let Some(idx) = session_path.find("/dvr-") { - let mut joined = parsed.clone(); - joined.set_path(&format!("{}{}", &session_path[..=idx], rel)); - joined.set_query(None); - joined.into() - } else { - parsed.join(rel).ok()?.into() - } - } else if let Some(idx) = session_path.find("/202") { - let mut joined = parsed.clone(); - joined.set_path(&format!("{}/{}", &session_path[..idx], rel)); - joined.set_query(None); - joined.into() - } else { - parsed.join(rel).ok()?.into() - }; - - if let Some(query) = request_query.filter(|q| !q.is_empty()) { - Some(format!("{joined}?{query}")) - } else { - Some(joined) - } -} - -fn legacy_hls_route_allowed_with_cache( - cache_enabled: bool, - decoded_session_token: Option<&str>, - existing_session_token: Option<&str>, -) -> bool { - !cache_enabled - || decoded_session_token.is_some_and(|decoded| { - existing_session_token.is_some_and(|existing| decoded == existing && is_m3u_catchup_session_token(existing)) - }) -} - -fn query_flag_is_archive(key: &str) -> bool { key.eq_ignore_ascii_case("utc") || key.eq_ignore_ascii_case("utcstart") } - -fn query_flag_marks_start_context(key: &str) -> bool { - key.eq_ignore_ascii_case("end") - || key.eq_ignore_ascii_case("duration") - || key.eq_ignore_ascii_case("lutc") - || key.eq_ignore_ascii_case("offset") -} - -pub(in crate::api) fn m3u_archive_epg_reference_ts(stream_url: &str) -> Option { - use crate::iptv::m3u::parse_flussonic_archive_file; - - let parsed = Url::parse(stream_url).ok()?; - // Flussonic / TiviMate path forms: archive|index|video|mono-{utc}-{duration}.m3u8 - // and timeshift_abs / timeshift_rel. Without this, HLS sessions stay LiveHls in the panel. - if let Some(file) = parsed.path_segments().and_then(|mut segments| segments.next_back()) { - if let Some(archive) = parse_flussonic_archive_file(file) { - if let Some(ts) = archive.epg_reference_ts() { - return Some(ts); - } - } - } - // BitTV date-tree: /YYYY/MM/DD/HH/MM/SS-*.ts - if let Some(ts) = epg_reference_ts_from_date_tree_path(parsed.path()) { - return Some(ts); - } - let mut start_ts = None; - let mut has_start_context = false; - for (key, value) in parsed.query_pairs() { - if query_flag_is_archive(&key) { - if let Ok(ts) = value.parse::() { - return Some(ts); - } - } else if key.eq_ignore_ascii_case("start") || key.eq_ignore_ascii_case("timestamp") { - start_ts = value.parse::().ok(); - } else if query_flag_marks_start_context(&key) { - has_start_context = true; - } - } - - has_start_context.then_some(start_ts).flatten() -} - -#[derive(Debug, Deserialize)] -struct HlsApiPathParams { - username: String, - password: String, - target_id: u16, - input_id: u16, - stream_id: u32, - /// Single obfuscated token, or a leaked relative origin path (`dvr-YYYY/...ts`). - token: String, -} - -#[derive(Debug, Deserialize)] -struct HlsProxySegmentPathParams { - proxy_session_id: String, - hls_access_lease_id: String, - segment_file: String, -} - -#[derive(Debug, Deserialize)] -struct HlsProxyManifestPathParams { - proxy_session_id: String, - hls_access_lease_id: String, -} - -#[derive(Debug, Deserialize)] -struct HlsProxyMapPathParams { - proxy_session_id: String, - hls_access_lease_id: String, - map_file: String, -} - -#[derive(Debug, Deserialize)] -struct HlsProxyResourcePathParams { - proxy_session_id: String, - hls_access_lease_id: String, - resource_file: String, -} - -#[derive(Debug, Deserialize)] -struct HlsProxyTerminalSegmentPathParams { - proxy_session_id: String, - hls_access_lease_id: String, - generation: String, - terminal_file: String, -} - -fn hls_custom_video_type_for_failure_reason(reason: ConnectFailureReason) -> CustomVideoStreamType { - match reason { - ConnectFailureReason::UserAccountExpired => CustomVideoStreamType::UserAccountExpired, - ConnectFailureReason::UserConnectionsExhausted => CustomVideoStreamType::UserConnectionsExhausted, - ConnectFailureReason::ProviderConnectionsExhausted => CustomVideoStreamType::ProviderConnectionsExhausted, - ConnectFailureReason::Preempted => CustomVideoStreamType::LowPriorityPreempted, - ConnectFailureReason::Provisioning => CustomVideoStreamType::Provisioning, - ConnectFailureReason::SessionExpired => CustomVideoStreamType::HlsSessionOrLeaseExpired, - ConnectFailureReason::ProviderError - | ConnectFailureReason::ProviderClosed - | ConnectFailureReason::ChannelUnavailable => CustomVideoStreamType::ChannelUnavailable, - } -} - -pub(crate) async fn hls_custom_video_manifest_response( - app_state: &Arc, - user: &ProxyUserCredentials, - video_type: CustomVideoStreamType, - fallback_status: StatusCode, -) -> axum::response::Response { - hls_custom_video_manifest_response_with_virtual_id(app_state, user, video_type, fallback_status, None).await -} - -pub(crate) async fn hls_admission_failure_manifest_response( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - stream_channel: StreamChannel, - provider_name: Arc, - req_headers: &HeaderMap, - reason: ConnectFailureReason, -) -> axum::response::Response { - record_connect_failed_attempt(ConnectFailedAttempt { - app_state, - fingerprint, - user, - stream_channel, - provider_name, - req_headers, - reason, - failure_stage: FailureStage::Admission, - }); - hls_custom_video_manifest_response( - app_state, - user, - hls_custom_video_type_for_failure_reason(reason), - StatusCode::FORBIDDEN, - ) - .await -} - -fn apply_hls_proxy_public_path_prefix(hls_content: String, server_path: Option<&str>) -> String { - let Some(path_prefix) = normalize_hls_proxy_public_path_prefix(server_path) else { - return hls_content; - }; - - let uri_attr_prefix = format!("URI=\"{path_prefix}/hls/shared/live/"); - let hls_content = hls_content.replace("URI=\"/hls/shared/live/", &uri_attr_prefix); - if hls_content.is_empty() { - return hls_content; - } - let mut prefixed = String::with_capacity(hls_content.len().saturating_add(path_prefix.len().saturating_mul(4))); - - for part in hls_content.split_inclusive('\n') { - let (line, line_ending) = split_hls_line_ending(part); - if line.starts_with("/hls/shared/live/") { - prefixed.push_str(&path_prefix); - } - prefixed.push_str(line); - prefixed.push_str(line_ending); - } - - prefixed -} - -fn normalize_hls_proxy_public_path_prefix(server_path: Option<&str>) -> Option { - let path = server_path?.trim().trim_matches('/'); - if path.is_empty() { - return None; - } - Some(format!("/{path}")) -} - -fn split_hls_line_ending(part: &str) -> (&str, &str) { - if let Some(line) = part.strip_suffix("\r\n") { - (line, "\r\n") - } else if let Some(line) = part.strip_suffix('\n') { - (line, "\n") - } else { - (part, "") - } -} - -fn materialize_hls_access_manifest( - hls_content: &str, - lease_id: &HlsAccessLeaseId, - server_path: Option<&str>, -) -> String { - let hls_content = hls_content.replace(HLS_ACCESS_LEASE_ID_PLACEHOLDER, &lease_id.0); - apply_hls_proxy_public_path_prefix(hls_content, server_path) -} - -fn hls_access_manifest_uses_startup_view(lease_state: HlsAccessLeaseState) -> bool { - matches!(lease_state, HlsAccessLeaseState::Pending | HlsAccessLeaseState::Idle) -} - -fn materialize_shared_hls_access_manifest( - hls_content: &str, - lease_id: &HlsAccessLeaseId, - lease_state: HlsAccessLeaseState, - strip: &crate::model::StripConfig, - window_policy: HlsManifestWindowPolicy, - mode: &'static str, - server_path: Option<&str>, -) -> HlsMaterializedSharedManifest { - let (response_body, initial_strip_outcome) = if hls_access_manifest_uses_startup_view(lease_state) { - let view = materialize_initial_hls_strip_view(hls_content, strip, window_policy); - (view.body, Some(view.outcome)) - } else { - (Cow::Borrowed(hls_content), None) - }; - HlsMaterializedSharedManifest { - body: materialize_hls_access_manifest(&response_body, lease_id, server_path), - mode, - initial_strip_outcome, - } -} - -struct HlsMaterializedSharedManifest { - body: String, - mode: &'static str, - initial_strip_outcome: Option, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum HlsInitialStripLeaseSkipReason { - LeaseActivated, - LeaseNotStartupView, -} - -impl HlsInitialStripLeaseSkipReason { - const fn as_log_reason(self) -> &'static str { - match self { - Self::LeaseActivated => "lease-activated", - Self::LeaseNotStartupView => "lease-not-startup-view", - } - } -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum HlsInitialStripPublicationDiagnostic { - Applied { mode: &'static str, strip_mode: &'static str, configured: u64, effective: usize, visible_segments: usize }, - Skipped { mode: &'static str, reason: HlsInitialStripSkipReason, visible_segments: usize }, - SkippedForLeaseState { mode: &'static str, reason: HlsInitialStripLeaseSkipReason }, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum HlsInitialStripPublicationStatus { - NotCommitted, - Committed, -} - -fn hls_initial_strip_publication_diagnostic( - publication_status: HlsInitialStripPublicationStatus, - lease_state: HlsAccessLeaseState, - materialized: &HlsMaterializedSharedManifest, -) -> Option { - match publication_status { - HlsInitialStripPublicationStatus::NotCommitted => return None, - HlsInitialStripPublicationStatus::Committed => {} - } - Some(match &materialized.initial_strip_outcome { - Some(HlsInitialStripOutcome::Applied { mode: strip_mode, configured, effective, visible_segments }) => { - HlsInitialStripPublicationDiagnostic::Applied { - mode: materialized.mode, - strip_mode, - configured: *configured, - effective: *effective, - visible_segments: *visible_segments, - } - } - Some(HlsInitialStripOutcome::Skipped { reason, visible_segments }) => { - HlsInitialStripPublicationDiagnostic::Skipped { - mode: materialized.mode, - reason: *reason, - visible_segments: *visible_segments, - } - } - None => HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { - mode: materialized.mode, - reason: if lease_state == HlsAccessLeaseState::Activated { - HlsInitialStripLeaseSkipReason::LeaseActivated - } else { - HlsInitialStripLeaseSkipReason::LeaseNotStartupView - }, - }, - }) -} - -fn log_hls_initial_strip_publication( - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - diagnostic: HlsInitialStripPublicationDiagnostic, -) { - match diagnostic { - HlsInitialStripPublicationDiagnostic::Applied { mode, strip_mode, configured, effective, visible_segments } => { - debug!( - "HLS initial strip applied: mode={} lease={} proxy_session={} strip_mode={} configured={} effective={} visible_segments={}", - mode, - safe_hls_access_lease_id(lease_id), - safe_proxy_session_id(proxy_session_id), - strip_mode, - configured, - effective, - visible_segments - ); - } - HlsInitialStripPublicationDiagnostic::Skipped { mode, reason, visible_segments } => { - debug!( - "HLS initial strip skipped: mode={} lease={} proxy_session={} reason={} visible_segments={}", - mode, - safe_hls_access_lease_id(lease_id), - safe_proxy_session_id(proxy_session_id), - reason.as_log_reason(), - visible_segments - ); - } - HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { mode, reason } => { - debug!( - "HLS initial strip skipped: mode={} lease={} proxy_session={} reason={}", - mode, - safe_hls_access_lease_id(lease_id), - safe_proxy_session_id(proxy_session_id), - reason.as_log_reason() - ); - } - } -} - -fn stripped_tail_segments(materialized: &HlsMaterializedSharedManifest) -> usize { - materialized.initial_strip_outcome.as_ref().map_or(0, |outcome| match outcome { - HlsInitialStripOutcome::Applied { effective, .. } => *effective, - HlsInitialStripOutcome::Skipped { .. } => 0, - }) -} - -fn hls_access_lease_ttl_ms(app_state: &Arc) -> u64 { app_state.hls_proxy.session_idle_timeout_ms() } - -fn duration_to_millis_saturating(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } - -fn hls_pending_bootstrap_window_ms(app_state: &Arc) -> u64 { - duration_to_millis_saturating(hls_initial_manifest_decision_wait_timeout(app_state)) -} - -async fn hls_access_lease_timing_for_session( - app_state: &Arc, - session: &HlsSessionHandle, -) -> HlsAccessLeaseTiming { - let timing = session.read().await.account_overlap_timing(); - let active_window_ms = timing.hard_active_window_ms.saturating_mul(2); - HlsAccessLeaseTiming { active_window_ms, valid_window_ms: hls_access_lease_ttl_ms(app_state) } -} - -async fn touch_pending_manifest_follow_up_window( - app_state: &Arc, - session: &HlsSessionHandle, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, -) { - if access_lease_state != HlsAccessLeaseState::Pending { - return; - } - let (proxy_session_id, target_duration) = { - let session = session.read().await; - (session.proxy_session_id.clone(), session.target_duration) - }; - let now_ms = current_time_millis(); - if !app_state - .hls_proxy - .mark_pending_manifest_follow_up_for_lease(access_lease_id, &proxy_session_id, now_ms, target_duration) - .await - { - debug!( - "HLS pending manifest follow-up skipped: lease={} proxy_session={} reason=expired-or-generation-race", - safe_hls_access_lease_id(access_lease_id), - safe_proxy_session_id(&proxy_session_id) - ); - } -} - -struct HlsResourceAccess { - session: HlsSessionHandle, - access_context: HlsAccessContext, - lease: HlsAccessLease, -} - -async fn prepare_hls_resource_access( - app_state: &Arc, - fingerprint: &Fingerprint, - proxy_session_id: &ProxySessionId, - hls_access_lease_id: &str, - now_ms: u64, - request_kind: &'static str, -) -> Result> { - let Some(session) = app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await else { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - }; - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - proxy_session_id, - now_ms, - ) - .await; - let access_context = match validate_hls_proxy_access_request( - app_state, - fingerprint, - proxy_session_id, - hls_access_lease_id, - now_ms, - hls_access_lease_timing_for_session(app_state, &session).await, - request_kind, - ) - .await - { - Ok(context) => context, - Err(err) => { - return Err(Box::new(hls_resource_access_lease_validation_response(&err))); - } - }; - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - proxy_session_id, - now_ms, - ) - .await; - reclaim_hls_account_overlap_if_needed(app_state, &session, now_ms).await; - let Some(lease) = - app_state.hls_proxy.access_lease_response_snapshot(&access_context.lease_id, proxy_session_id, now_ms).await - else { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - }; - Ok(HlsResourceAccess { session, access_context, lease }) -} - -fn hls_lease_allows_live_origin_work(lease: &HlsAccessLease) -> bool { - matches!(lease.state, HlsAccessLeaseState::Pending | HlsAccessLeaseState::Activated | HlsAccessLeaseState::Idle) - && lease.playback_mode == HlsLeasePlaybackMode::Live -} - -fn hls_lease_allows_cached_segment(lease: &HlsAccessLease, proxy_seq: u64) -> bool { - match &lease.playback_mode { - HlsLeasePlaybackMode::Live => true, - HlsLeasePlaybackMode::TerminalTail(plan) => plan.protected_base_proxy_seqs.contains(&proxy_seq), - HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => false, - } -} - -async fn current_hls_resource_lease( - app_state: &Arc, - access_context: &HlsAccessContext, -) -> Option { - app_state - .hls_proxy - .access_lease_response_snapshot( - &access_context.lease_id, - &access_context.proxy_session_id, - current_time_millis(), - ) - .await -} - -async fn hls_live_lease_identity_is_current( - app_state: &Arc, - access_context: &HlsAccessContext, - expected_identity: HlsMediaLeaseIdentity, -) -> bool { - current_hls_resource_lease(app_state, access_context).await.is_some_and(|lease| { - lease.playback_mode == HlsLeasePlaybackMode::Live && lease.media_identity() == Some(expected_identity) - }) -} - -fn create_hls_cache_user_session_token( - fingerprint: &Fingerprint, - username: &str, - virtual_id: u32, - existing_session_token: Option<&str>, - archive_reference: Option, -) -> String { - let base = - hls_entry_user_session_token(fingerprint, username, virtual_id, existing_session_token, archive_reference); - format!("{base}|hls-cache|{}", generate_random_string(16)) -} - -fn is_hls_media_activity_status(status: StatusCode) -> bool { - matches!(status, StatusCode::OK | StatusCode::PARTIAL_CONTENT) -} - -async fn hls_cache_response_context( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - lease_identity: HlsMediaLeaseIdentity, - now_ms: u64, -) -> HlsCacheResponseContext { - let qos_meter = app_state.hls_proxy.qos().meter_for_access_lease(&access_context.lease_id).await; - let log_identity = { - let session = session.read().await; - HlsLogIdentity::from_session(&session) - }; - HlsCacheResponseContext::new( - access_context.lease_id.clone(), - log_identity, - app_state.hls_proxy.cache_duration_seconds(), - Arc::clone(app_state.hls_proxy.metrics()), - Arc::clone(app_state.hls_proxy.segment_repair()), - qos_meter, - Some(HlsMediaActivityMarker::new( - Arc::clone(&app_state.hls_proxy), - Arc::clone(session), - access_context.proxy_session_id.clone(), - access_context.lease_id.clone(), - lease_identity, - )), - now_ms, - ) -} - -fn hls_qos_meter_init(app_state: &Arc, qos_config: HlsQosRuntimeConfig) -> Option { - if !qos_config.live_metering_enabled { - return None; - } - let meter_uid = app_state.connection_manager.next_stream_uid(); - let meter = Arc::new(StreamMeterHandle::new(meter_uid, Arc::downgrade(&app_state.event_manager))); - Some(HlsQosMeterInit { meter_uid, meter }) -} - -async fn register_hls_cache_stream_for_successful_media_response( - app_state: &Arc, - fingerprint: &Fingerprint, - headers: &HeaderMap, - access_context: &HlsAccessContext, - session: &HlsSessionHandle, - response_context: &HlsCacheResponseContext, -) { - if ensure_hls_cache_stream_registered(app_state, fingerprint, headers, access_context, session).await.is_none() { - debug!( - "HLS media registration skipped: lease={} reason=session-or-connection-unavailable", - safe_hls_access_lease_id(&access_context.lease_id) - ); - } - response_context.set_qos_meter(app_state.hls_proxy.qos().meter_for_access_lease(&access_context.lease_id).await); -} - -async fn hls_proxy_segment( - fingerprint: Fingerprint, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, - headers: HeaderMap, -) -> axum::response::Response { - let proxy_session_id = ProxySessionId(params.proxy_session_id); - let now_ms = current_time_millis(); - let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( - &app_state, - &fingerprint, - &proxy_session_id, - ¶ms.hls_access_lease_id, - now_ms, - "segment", - ) - .await - { - Ok(access) => access, - Err(response) => return *response, - }; - let Some(segment_file) = HlsSegmentFile::parse(¶ms.segment_file) else { - return hls_resource_channel_unavailable_response(&app_state, &access_context); - }; - if !hls_lease_allows_cached_segment(&lease, segment_file.proxy_seq) { - return StatusCode::NOT_FOUND.into_response(); - } - let allows_origin_work = hls_lease_allows_live_origin_work(&lease); - if let Err(response) = - validate_hls_segment_entry(&app_state, &session, &access_context, &segment_file, allows_origin_work).await - { - return *response; - } - let demand_result = if allows_origin_work { - demand_fetch_hls_live_segment( - &app_state, - &session, - &segment_file, - &access_context, - &fingerprint, - &headers, - now_ms, - ) - .await - } else { - Ok(()) - }; - if let Err(response) = demand_result { - return *response; - } - - serve_hls_segment_for_current_lease( - &app_state, - &session, - &access_context, - &fingerprint, - &headers, - segment_file, - now_ms, - ) - .await -} - -async fn validate_hls_segment_entry( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - segment_file: &HlsSegmentFile, - allows_origin_work: bool, -) -> Result<(), Box> { - let session = session.read().await; - if session.is_gc_marked_for_removal() { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - } - let Some(entry) = session.segments.get(&segment_file.proxy_seq) else { - return Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))); - }; - if entry.proxy_file_ext != segment_file.extension { - return Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))); - } - if !allows_origin_work && !matches!(&entry.status, SegmentCacheStatus::Ready { .. }) { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - } - Ok(()) -} - -async fn demand_fetch_hls_live_segment( - app_state: &Arc, - session: &HlsSessionHandle, - segment_file: &HlsSegmentFile, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - headers: &HeaderMap, - now_ms: u64, -) -> Result<(), Box> { - let preacquired_provider_handle = if hls_segment_request_requires_origin_work(session, segment_file).await { - match prepare_hls_origin_binding_for_authorized_resource_work( - app_state, - session, - access_context, - fingerprint, - headers, - HlsOriginWorkKind::Segment, - now_ms, - ) - .await - { - Ok(handle) => handle, - Err(err) => { - return Err(Box::new(hls_origin_runtime_resource_failure_response(app_state, access_context, err))) - } - } - } else { - None - }; - match demand_fetch_hls_segment_if_needed( - app_state, - session, - segment_file, - access_context, - fingerprint, - preacquired_provider_handle, - now_ms, - ) - .await - { - SegmentDemandFetchOutcome::NotFound => { - Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))) - } - SegmentDemandFetchOutcome::Ready - | SegmentDemandFetchOutcome::QueuedOrFetching - | SegmentDemandFetchOutcome::Unavailable - | SegmentDemandFetchOutcome::TimedOut => Ok(()), - } -} - -async fn serve_hls_segment_for_current_lease( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - headers: &HeaderMap, - segment_file: HlsSegmentFile, - now_ms: u64, -) -> axum::response::Response { - let Some(current_lease) = current_hls_resource_lease(app_state, access_context).await else { - return StatusCode::NOT_FOUND.into_response(); - }; - if !hls_lease_allows_cached_segment(¤t_lease, segment_file.proxy_seq) { - return StatusCode::NOT_FOUND.into_response(); - } - let current_allows_origin_work = hls_lease_allows_live_origin_work(¤t_lease); - let Some(lease_identity) = current_lease.media_identity() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let response_context = hls_cache_response_context(app_state, session, access_context, lease_identity, now_ms).await; - let response = hls_resource_serve_outcome_response( - app_state, - access_context, - serve_hls_segment_cache_outcome( - Arc::clone(app_state.hls_proxy.segment_cache()), - Arc::clone(session), - segment_file, - headers.get(header::RANGE).cloned(), - &response_context, - ) - .await, - ); - if current_allows_origin_work && is_hls_media_activity_status(response.status()) { - register_hls_cache_stream_for_successful_media_response( - app_state, - fingerprint, - headers, - access_context, - session, - &response_context, - ) - .await; - } - response -} - -async fn hls_proxy_terminal_segment( - fingerprint: Fingerprint, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, - method: Method, - headers: HeaderMap, -) -> axum::response::Response { - let proxy_session_id = ProxySessionId(params.proxy_session_id); - let Some(path) = HlsTerminalSegmentPath::parse(¶ms.generation, ¶ms.terminal_file) else { - return StatusCode::NOT_FOUND.into_response(); - }; - let now_ms = current_time_millis(); - let access_lease_id = HlsAccessLeaseId(params.hls_access_lease_id.clone()); - let immutable_replay_plan = - app_state.hls_proxy.access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms).await.and_then( - |lease| match lease.playback_mode { - HlsLeasePlaybackMode::TerminalTail(plan) if plan.matches_route(&proxy_session_id, &access_lease_id) => { - Some(plan) - } - HlsLeasePlaybackMode::Live - | HlsLeasePlaybackMode::TerminalTail(_) - | HlsLeasePlaybackMode::TerminalUnavailable { .. } - | HlsLeasePlaybackMode::Ended => None, - }, - ); - let access = prepare_hls_resource_access( - &app_state, - &fingerprint, - &proxy_session_id, - ¶ms.hls_access_lease_id, - now_ms, - "terminal-segment", - ) - .await; - let Ok(HlsResourceAccess { session, access_context, lease }) = access else { - return immutable_replay_plan - .and_then(|plan| { - terminal_segment_immutable_replay_response( - &plan, - path, - headers.get(header::RANGE), - method == Method::HEAD, - ) - }) - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); - }; - let Some(current_lease) = current_hls_resource_lease(&app_state, &access_context).await else { - return StatusCode::NOT_FOUND.into_response(); - }; - let Some(plan) = - terminal_tail_plan_for_current_route(&lease, ¤t_lease, &proxy_session_id, &access_context.lease_id) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - if method == Method::HEAD { - return terminal_segment_head_response(&plan, path, headers.get(header::RANGE)) - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); - } - let Some(lease_identity) = current_lease.media_identity() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let response_context = - hls_cache_response_context(&app_state, &session, &access_context, lease_identity, now_ms).await; - let Some(response) = - terminal_segment_get_response(&plan, path, headers.get(header::RANGE), &response_context, &proxy_session_id) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - if is_hls_media_activity_status(response.status()) { - response_context.mark_media_activity().await; - register_hls_cache_stream_for_successful_media_response( - &app_state, - &fingerprint, - &headers, - &access_context, - &session, - &response_context, - ) - .await; - } - response -} - -async fn demand_fetch_hls_segment_if_needed( - app_state: &Arc, - session: &HlsSessionHandle, - segment_file: &HlsSegmentFile, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - preacquired_provider_handle: Option, - now_ms: u64, -) -> SegmentDemandFetchOutcome { - let context = build_hls_segment_fetch_context( - app_state, - session, - Some(access_context.lease_id.clone()), - fingerprint, - preacquired_provider_handle, - ) - .await; - app_state.hls_proxy.segment_worker_pool().demand_fetch_and_wait(context, segment_file, now_ms).await -} - -async fn build_hls_segment_fetch_context( - app_state: &Arc, - session: &HlsSessionHandle, - repair_access_lease_id: Option, - fingerprint: &Fingerprint, - preacquired_provider_handle: Option, -) -> SegmentFetchContext { - let (headers, origin_provider_session_headers, origin_policy, reservation_ttl_secs) = { - let session = session.read().await; - ( - session.origin_request_headers.clone(), - session.origin_provider_session_headers.clone(), - session.effective_origin_acquire_policy_or_default(), - session.account_overlap_timing().reservation_ttl_secs(), - ) - }; - let mut origin_io = HlsOriginIoContext { - ctx: app_state.hls_ctx(), - client_addr: fingerprint.addr, - allow_grace: HlsOriginWorkClass::Demand.allows_grace(), - priority: origin_policy.priority, - connection_kind: origin_policy.connection_kind, - reservation_ttl_secs, - preacquired_provider_handle: None, - started_generation: None, - }; - if let Some(provider_handle) = preacquired_provider_handle { - origin_io = origin_io.with_preacquired_provider_handle(provider_handle); - } - SegmentFetchContext { - session: Arc::clone(session), - segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), - segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), - repair_access_lease_id, - headers, - origin_provider_session_headers, - client: app_state.http_client.load().as_ref().clone(), - no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), - use_manual_redirects: app_state.should_use_manual_redirects(), - origin_io: Some(origin_io), - } -} - -async fn hls_effective_origin_acquire_policy(session: &HlsSessionHandle) -> HlsEffectiveOriginAcquirePolicy { - session.read().await.effective_origin_acquire_policy_or_default() -} - -async fn hls_origin_account_reservation_ttl_secs_for_session(session: &HlsSessionHandle) -> u64 { - session.read().await.account_overlap_timing().reservation_ttl_secs() -} - -fn hls_origin_account_reservation_ttl_secs_fallback() -> u64 { - HlsAccountOverlapTiming::from_target_duration_secs(None).reservation_ttl_secs() -} - -async fn hls_proxy_map( - fingerprint: Fingerprint, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, - headers: HeaderMap, -) -> axum::response::Response { - let proxy_session_id = ProxySessionId(params.proxy_session_id); - let now_ms = current_time_millis(); - let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( - &app_state, - &fingerprint, - &proxy_session_id, - ¶ms.hls_access_lease_id, - now_ms, - "map", - ) - .await - { - Ok(access) => access, - Err(response) => return *response, - }; - if !hls_lease_allows_live_origin_work(&lease) { - return StatusCode::NOT_FOUND.into_response(); - } - let Some(map_file) = HlsMapFile::parse(¶ms.map_file) else { - return hls_resource_channel_unavailable_response(&app_state, &access_context); - }; - { - let session_guard = session.read().await; - if session_guard.is_gc_marked_for_removal() { - return StatusCode::NOT_FOUND.into_response(); - } - let Some(entry) = session_guard.maps.get(&map_file.proxy_map_id.into()) else { - return hls_resource_channel_unavailable_response(&app_state, &access_context); - }; - if entry.proxy_file_ext != map_file.extension { - return hls_resource_channel_unavailable_response(&app_state, &access_context); - } - } - - let Some(current_lease) = current_hls_resource_lease(&app_state, &access_context).await else { - return StatusCode::NOT_FOUND.into_response(); - }; - if current_lease.playback_mode != HlsLeasePlaybackMode::Live { - return StatusCode::NOT_FOUND.into_response(); - } - let Some(lease_identity) = current_lease.media_identity() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let response_context = - hls_cache_response_context(&app_state, &session, &access_context, lease_identity, now_ms).await; - let response = hls_resource_serve_outcome_response( - &app_state, - &access_context, - serve_hls_map_cache_outcome( - Arc::clone(app_state.hls_proxy.segment_cache()), - Arc::clone(&session), - map_file, - headers.get(header::RANGE).cloned(), - &response_context, - ) - .await, - ); - if is_hls_media_activity_status(response.status()) { - register_hls_cache_stream_for_successful_media_response( - &app_state, - &fingerprint, - &headers, - &access_context, - &session, - &response_context, - ) - .await; - } - response -} - -async fn hls_proxy_resource( - fingerprint: Fingerprint, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, - headers: HeaderMap, -) -> axum::response::Response { - let proxy_session_id = ProxySessionId(params.proxy_session_id); - let now_ms = current_time_millis(); - let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( - &app_state, - &fingerprint, - &proxy_session_id, - ¶ms.hls_access_lease_id, - now_ms, - "resource", - ) - .await - { - Ok(access) => access, - Err(response) => return *response, - }; - let Some(resource_file) = TransientResourceFile::parse(¶ms.resource_file) else { - return hls_resource_channel_unavailable_response(&app_state, &access_context); - }; - let Some(lease_identity) = lease.media_identity() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let context = HlsResourceEndpointContext { - app_state: &app_state, - session: &session, - fingerprint: &fingerprint, - headers: &headers, - access_context: &access_context, - lease_identity, - published_resource_ids: lease.published_transient_resource_ids().clone(), - resource_file, - range_header: headers.get(header::RANGE).cloned(), - now_ms, - }; - match &lease.playback_mode { - HlsLeasePlaybackMode::Live => serve_hls_live_transient_resource(context).await, - HlsLeasePlaybackMode::TerminalTail(_) => serve_hls_terminal_key_resource(context, &lease.playback_mode).await, - HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => { - StatusCode::NOT_FOUND.into_response() - } - } -} - -struct HlsResourceEndpointContext<'a> { - app_state: &'a Arc, - session: &'a HlsSessionHandle, - fingerprint: &'a Fingerprint, - headers: &'a HeaderMap, - access_context: &'a HlsAccessContext, - lease_identity: HlsMediaLeaseIdentity, - published_resource_ids: HlsPublishedTransientResourceIds, - resource_file: TransientResourceFile, - range_header: Option, - now_ms: u64, -} - -async fn serve_hls_terminal_key_resource( - context: HlsResourceEndpointContext<'_>, - playback_mode: &HlsLeasePlaybackMode, -) -> axum::response::Response { - let HlsLeasePlaybackMode::TerminalTail(plan) = playback_mode else { - return StatusCode::NOT_FOUND.into_response(); - }; - let proxy_session_id = &context.access_context.proxy_session_id; - let Some(binding) = - plan.terminal_key_binding(proxy_session_id, &context.access_context.lease_id, &context.resource_file) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - if !context.session.read().await.terminal_key_binding_is_current( - &context.access_context.lease_id, - plan.generation, - &binding, - ) { - return StatusCode::NOT_FOUND.into_response(); - } - let response_context = hls_cache_response_context( - context.app_state, - context.session, - context.access_context, - context.lease_identity, - context.now_ms, - ) - .await; - let response = finite_hls_terminal_key_response( - binding.bytes(), - context.range_header.as_ref(), - binding.content_type(), - "private, max-age=300, immutable", - &response_context, - proxy_session_id, - context.resource_file.resource_id.0, - ); - if is_hls_media_activity_status(response.status()) { - response_context.mark_media_activity().await; - register_hls_cache_stream_for_successful_media_response( - context.app_state, - context.fingerprint, - context.headers, - context.access_context, - context.session, - &response_context, - ) - .await; - } - response -} - -async fn serve_hls_live_transient_resource(context: HlsResourceEndpointContext<'_>) -> axum::response::Response { - let cache_duration_ms = context.app_state.hls_proxy.cache_duration_seconds().saturating_mul(1_000); - let Ok(cache_resolution) = resolve_hls_transient_object_cache_action( - context.session, - &context.access_context.proxy_session_id, - HlsTransientResourceLeaseContext { - access_lease_id: &context.access_context.lease_id, - lease_issued_at_ms: context.lease_identity.lease_issued_at_ms(), - published_resource_ids: &context.published_resource_ids, - }, - &context.resource_file, - context.range_header.as_ref(), - context.now_ms, - cache_duration_ms, - ) - .await - else { - return hls_resource_channel_unavailable_response(context.app_state, context.access_context); - }; - let resource = cache_resolution.resource; - let origin_headers = cache_resolution.origin_headers; - let origin_provider_session_headers = cache_resolution.origin_provider_session_headers; - let cache_action = cache_resolution.action; - - match cache_action { - HlsTransientObjectCacheAction::ServeReady => { - return serve_transient_object_cache_response_and_mark_or_unavailable(TransientObjectCacheServeContext { - app_state: context.app_state, - session: context.session, - fingerprint: context.fingerprint, - headers: context.headers, - access_context: context.access_context, - lease_identity: context.lease_identity, - resource_file: context.resource_file, - range_header: context.range_header, - now_ms: context.now_ms, - }) - .await; - } - HlsTransientObjectCacheAction::WaitForFetch(notifier) => { - return wait_for_transient_object_cache_fetch(TransientObjectWaitContext { - app_state: context.app_state, - session: context.session, - fingerprint: context.fingerprint, - headers: context.headers, - access_context: context.access_context, - lease_identity: context.lease_identity, - resource_file: context.resource_file, - range_header: context.range_header, - notifier, - }) - .await; - } - HlsTransientObjectCacheAction::FetchAndCache(_) | HlsTransientObjectCacheAction::PassthroughNoCache => {} - } - - fetch_or_passthrough_transient_resource(HlsTransientPassthroughContext { - endpoint: context, - resource, - cache_action, - origin_headers, - origin_provider_session_headers, - cache_duration_ms, - }) - .await -} - -struct HlsTransientPassthroughContext<'a> { - endpoint: HlsResourceEndpointContext<'a>, - resource: TransientResourceRef, - cache_action: HlsTransientObjectCacheAction, - origin_headers: HeaderMap, - origin_provider_session_headers: HeaderMap, - cache_duration_ms: u64, -} - -async fn fetch_or_passthrough_transient_resource( - context: HlsTransientPassthroughContext<'_>, -) -> axum::response::Response { - let HlsTransientPassthroughContext { - endpoint, - resource, - cache_action, - origin_headers, - origin_provider_session_headers, - cache_duration_ms, - } = context; - if let HlsTransientObjectCacheAction::FetchAndCache(fetch_token) = cache_action { - return fetch_and_cache_transient_origin_response(HlsTransientEndpointCacheFetchContext { - app_state: endpoint.app_state, - session: endpoint.session, - fingerprint: endpoint.fingerprint, - headers: endpoint.headers, - access_context: endpoint.access_context, - lease_identity: endpoint.lease_identity, - resource: &resource, - resource_file: endpoint.resource_file, - fetch_token: *fetch_token, - origin_headers, - origin_provider_session_headers, - range_header: endpoint.range_header, - cache_duration_ms, - }) - .await; - } - - let policy = endpoint.app_state.hls_proxy.segment_fetch_policy(); - let fetch_result = fetch_transient_origin_response_with_provider_io(HlsTransientEndpointOriginFetchRequest { - app_state: endpoint.app_state, - session: endpoint.session, - access_context: endpoint.access_context, - fingerprint: endpoint.fingerprint, - headers: endpoint.headers, - resource: &resource, - resource_file: &endpoint.resource_file, - origin_headers, - origin_provider_session_headers, - range_header: endpoint.range_header.clone(), - policy: policy.clone(), - }) - .await; - serve_hls_transient_passthrough_result(endpoint, resource, policy, fetch_result).await -} - -async fn serve_hls_transient_passthrough_result( - endpoint: HlsResourceEndpointContext<'_>, - resource: TransientResourceRef, - policy: SegmentFetchPolicy, - fetch_result: HlsTransientOriginFetchResult, -) -> axum::response::Response { - match fetch_result.result { - Ok(response) => { - if response.decoded.status.is_success() { - let activity_outcome = endpoint - .app_state - .hls_proxy - .mark_authorized_media_access_for_lease_if_identity_matches( - endpoint.session, - &endpoint.access_context.lease_id, - &endpoint.access_context.proxy_session_id, - endpoint.lease_identity, - endpoint.now_ms, - ) - .await; - match activity_outcome { - HlsMediaActivityCommitOutcome::Committed => {} - HlsMediaActivityCommitOutcome::StaleLeaseIdentity => { - debug!( - "HLS transient media response discarded: lease={} proxy_session={} reason=playback-generation-race", - safe_hls_access_lease_id(&endpoint.access_context.lease_id), - safe_proxy_session_id(&endpoint.access_context.proxy_session_id) - ); - return StatusCode::NOT_FOUND.into_response(); - } - HlsMediaActivityCommitOutcome::DeferredLockContention => { - debug!( - "HLS transient media response deferred: lease={} proxy_session={} reason=lock-contention", - safe_hls_access_lease_id(&endpoint.access_context.lease_id), - safe_proxy_session_id(&endpoint.access_context.proxy_session_id) - ); - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - } - } - if ensure_hls_cache_stream_registered( - endpoint.app_state, - endpoint.fingerprint, - endpoint.headers, - endpoint.access_context, - endpoint.session, - ) - .await - .is_none() - { - debug!( - "HLS transient media registration skipped: lease={} reason=session-or-connection-unavailable", - safe_hls_access_lease_id(&endpoint.access_context.lease_id) - ); - } - } - hls_transient_origin_response( - response, - HlsTransientDirectResponseContext { - session: Arc::clone(endpoint.session), - resource, - policy: policy.clone(), - now_ms: endpoint.now_ms, - log_identity: { - let session = endpoint.session.read().await; - HlsLogIdentity::from_session(&session) - }, - }, - ) - } - Err(err) => { - if matches!(err, HlsOriginResourceFetchError::ProviderUnavailable(_)) { - if let Some(runtime_err) = fetch_result.runtime_prepare_error { - return hls_origin_runtime_resource_failure_response( - endpoint.app_state, - endpoint.access_context, - runtime_err, - ); - } - } - match hls_transient_object_fetch_failure(&err) { - HlsTransientObjectFetchFailure::Retryable => { - let failed_at_ms = current_time_millis(); - if record_temporary_transient_segment_fetch_failure( - endpoint.session, - &resource, - &policy, - failed_at_ms, - ) - .await - { - hls_resource_channel_unavailable_response(endpoint.app_state, endpoint.access_context) - } else { - hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) - } - } - HlsTransientObjectFetchFailure::Permanent { status: _ } => { - hls_resource_channel_unavailable_response(endpoint.app_state, endpoint.access_context) - } - } - } - } -} - -struct HlsTransientEndpointOriginFetchRequest<'a> { - app_state: &'a Arc, - session: &'a HlsSessionHandle, - access_context: &'a HlsAccessContext, - fingerprint: &'a Fingerprint, - headers: &'a HeaderMap, - resource: &'a TransientResourceRef, - resource_file: &'a TransientResourceFile, - origin_headers: HeaderMap, - origin_provider_session_headers: HeaderMap, - range_header: Option, - policy: SegmentFetchPolicy, -} - -struct HlsTransientOriginFetchResult { - result: Result>, HlsOriginResourceFetchError>, - runtime_prepare_error: Option, -} - -async fn fetch_transient_origin_response_with_provider_io( - request: HlsTransientEndpointOriginFetchRequest<'_>, -) -> HlsTransientOriginFetchResult { - let clients = HlsOriginResourceClients { - client: request.app_state.http_client.load().as_ref().clone(), - no_redirect_client: request.app_state.http_client_no_redirect.load().as_ref().clone(), - use_manual_redirects: request.app_state.should_use_manual_redirects(), - }; - let log_identity = { - let session = request.session.read().await; - HlsLogIdentity::from_session(&session) - }; - let fetch_request = HlsTransientOriginFetchRequest { - resolved_origin_uri: request.resource.resolved_origin_uri.clone(), - origin_headers: request.origin_headers, - origin_provider_session_headers: request.origin_provider_session_headers, - range_header: request.range_header, - resource_file: request.resource_file.clone(), - resource_kind: request.resource.kind, - clients, - policy: request.policy, - log_identity, - }; - let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); - let prepare_attempt = hls_transient_origin_prepare_closure( - request.app_state, - request.session, - request.access_context, - request.fingerprint, - request.headers, - &runtime_prepare_error, - ); - let result = fetch_hls_transient_origin_response_with_attempt_prepare(fetch_request, prepare_attempt).await; - let runtime_prepare_error = *runtime_prepare_error.lock().await; - HlsTransientOriginFetchResult { result, runtime_prepare_error } -} - -/// Builds the shared per-attempt prepare closure for transient origin fetches. -/// Runtime acquire failures are captured in `runtime_prepare_error` and mapped -/// to a provider-unavailable fetch error so the retry loop can proceed uniformly. -fn hls_transient_origin_prepare_closure( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - headers: &HeaderMap, - runtime_prepare_error: &Arc>>, -) -> impl FnMut( - HlsResourceFetchAttempt, -) -> futures::future::BoxFuture< - 'static, - Result, HlsOriginResourceFetchError>, -> { - let app_state = Arc::clone(app_state); - let session = Arc::clone(session); - let access_context = access_context.clone(); - let fingerprint = fingerprint.clone(); - let headers = headers.clone(); - let runtime_prepare_error = Arc::clone(runtime_prepare_error); - move |_attempt| { - let app_state = Arc::clone(&app_state); - let session = Arc::clone(&session); - let access_context = access_context.clone(); - let fingerprint = fingerprint.clone(); - let headers = headers.clone(); - let runtime_prepare_error = Arc::clone(&runtime_prepare_error); - async move { - match prepare_hls_transient_origin_io_for_authorized_resource_work( - &app_state, - &session, - &access_context, - &fingerprint, - &headers, - current_time_millis(), - ) - .await - { - Ok(guard) => Ok(guard), - Err(err) => { - *runtime_prepare_error.lock().await = Some(err); - Err(HlsOriginResourceFetchError::ProviderUnavailable(HlsBoundAccountAcquireErrorKind::Unavailable)) - } - } - } - .boxed() - } -} - -#[allow(clippy::too_many_arguments)] -struct HlsTransientEndpointCacheFetchContext<'a> { - app_state: &'a Arc, - session: &'a HlsSessionHandle, - fingerprint: &'a Fingerprint, - headers: &'a HeaderMap, - access_context: &'a HlsAccessContext, - lease_identity: HlsMediaLeaseIdentity, - resource: &'a TransientResourceRef, - resource_file: TransientResourceFile, - fetch_token: TransientObjectFetchToken, - origin_headers: HeaderMap, - origin_provider_session_headers: HeaderMap, - range_header: Option, - cache_duration_ms: u64, -} - -struct TransientObjectWaitContext<'a> { - app_state: &'a Arc, - session: &'a HlsSessionHandle, - fingerprint: &'a Fingerprint, - headers: &'a HeaderMap, - access_context: &'a HlsAccessContext, - lease_identity: HlsMediaLeaseIdentity, - resource_file: TransientResourceFile, - range_header: Option, - notifier: Arc, -} - -struct TransientObjectCacheServeContext<'a> { - app_state: &'a Arc, - session: &'a HlsSessionHandle, - fingerprint: &'a Fingerprint, - headers: &'a HeaderMap, - access_context: &'a HlsAccessContext, - lease_identity: HlsMediaLeaseIdentity, - resource_file: TransientResourceFile, - range_header: Option, - now_ms: u64, -} - -async fn serve_transient_object_cache_response_and_mark( - context: TransientObjectCacheServeContext<'_>, -) -> axum::response::Response { - if !hls_live_lease_identity_is_current(context.app_state, context.access_context, context.lease_identity).await { - return StatusCode::NOT_FOUND.into_response(); - } - let response_context = hls_cache_response_context( - context.app_state, - context.session, - context.access_context, - context.lease_identity, - context.now_ms, - ) - .await; - let response = hls_resource_serve_outcome_response( - context.app_state, - context.access_context, - serve_hls_transient_object_cache_outcome( - Arc::clone(context.app_state.hls_proxy.segment_cache()), - Arc::clone(context.session), - context.resource_file, - context.range_header, - &response_context, - ) - .await, - ); - if is_hls_media_activity_status(response.status()) { - register_hls_cache_stream_for_successful_media_response( - context.app_state, - context.fingerprint, - context.headers, - context.access_context, - context.session, - &response_context, - ) - .await; - } - response -} - -async fn serve_transient_object_cache_response_and_mark_or_unavailable( - context: TransientObjectCacheServeContext<'_>, -) -> axum::response::Response { - serve_transient_object_cache_response_and_mark(context).await -} - -async fn wait_for_transient_object_cache_fetch(context: TransientObjectWaitContext<'_>) -> axum::response::Response { - let wait_timeout = context.app_state.hls_proxy.segment_fetch_policy().origin_object_wait_timeout(); - let safe_resource_id = safe_transient_resource_id(&context.resource_file.resource_id); - debug!( - "HLS transient object wait started: resource_id={} lease={} state=inflight", - safe_resource_id, - safe_hls_access_lease_id(&context.access_context.lease_id) - ); - let wait_result = tokio::time::timeout(wait_timeout, context.notifier.notified()).await; - if wait_result.is_err() { - debug!( - "HLS transient object wait timed out: resource_id={} lease={} state=inflight", - safe_resource_id, - safe_hls_access_lease_id(&context.access_context.lease_id) - ); - return hls_transient_object_unavailable_response( - context.app_state, - context.session, - &context.resource_file, - current_time_millis(), - context.access_context, - ) - .await; - } - let response = serve_transient_object_cache_response_and_mark_or_unavailable(TransientObjectCacheServeContext { - app_state: context.app_state, - session: context.session, - fingerprint: context.fingerprint, - headers: context.headers, - access_context: context.access_context, - lease_identity: context.lease_identity, - resource_file: context.resource_file, - range_header: context.range_header, - now_ms: current_time_millis(), - }) - .await; - debug!( - "HLS transient object wait completed: resource_id={} lease={} status={}", - safe_resource_id, - safe_hls_access_lease_id(&context.access_context.lease_id), - response.status() - ); - response -} - -fn safe_transient_resource_id(resource_id: &TransientResourceId) -> String { - // Truncate at the first char boundary at or before byte 8 to avoid allocating - // a temporary `String` of 8 chars (and a second UTF-8 walk via `len()`). - let full = resource_id.0.as_str(); - let truncate_at = full.char_indices().nth(8).map_or(full.len(), |(byte_idx, _)| byte_idx); - if truncate_at == full.len() { - return full.to_owned(); - } - let mut out = String::with_capacity(truncate_at + 3); - out.push_str(&full[..truncate_at]); - out.push_str("..."); - out -} - -async fn validate_hls_proxy_access_request( - app_state: &Arc, - fingerprint: &Fingerprint, - proxy_session_id: &ProxySessionId, - hls_access_lease_id: &str, - now_ms: u64, - timing: HlsAccessLeaseTiming, - request_kind: &'static str, -) -> Result { - let context = validate_hls_proxy_access_context( - app_state, - fingerprint, - proxy_session_id, - hls_access_lease_id, - now_ms, - HlsAccessAdmissionMode::ResourceAccess, - ) - .await?; - let startup_admission_pending = app_state - .hls_proxy - .access_lease_response_snapshot(&context.lease_id, proxy_session_id, now_ms) - .await - .is_some_and(|lease| { - lease.state == HlsAccessLeaseState::Pending - && lease.startup_admission == HlsLeaseStartupAdmissionState::Pending - }); - if startup_admission_pending { - return Err(HlsAccessLeaseValidationError::AvailabilityPending); - } - match app_state.hls_proxy.activate_access_lease(&context.lease_id, proxy_session_id, now_ms, timing).await { - HlsAccessLeaseActivation::Activated { .. } => { - debug!( - "HLS access lease accepted: lease={} proxy_session={} user_session={} request={request_kind}", - safe_hls_access_lease_id(&context.lease_id), - safe_proxy_session_id(proxy_session_id), - safe_user_session_token(&context.user_session_token) - ); - Ok(context) - } - HlsAccessLeaseActivation::Denied => { - warn!( - "HLS access lease rejected: lease={} proxy_session={} user_session={} request={request_kind} reason=denied", - safe_hls_access_lease_id(&context.lease_id), - safe_proxy_session_id(proxy_session_id), - safe_user_session_token(&context.user_session_token) - ); - let (runtime_tail, reason) = app_state - .hls_proxy - .access_lease_response_snapshot(&context.lease_id, proxy_session_id, now_ms) - .await - .map_or((None, None), |lease| { - (lease.runtime_policy_revocation_outcome(), lease.runtime_policy_denial_reason()) - }); - Err(HlsAccessLeaseValidationError::AdmissionDenied { runtime_tail, reason }) - } - HlsAccessLeaseActivation::Expired - | HlsAccessLeaseActivation::UnknownLease - | HlsAccessLeaseActivation::SessionMismatch => { - warn!( - "HLS access lease rejected: lease={} proxy_session={} user_session={} request={request_kind} reason=expired", - safe_hls_access_lease_id(&context.lease_id), - safe_proxy_session_id(proxy_session_id), - safe_user_session_token(&context.user_session_token) - ); - Err(HlsAccessLeaseValidationError::Expired) - } - } -} - -async fn ensure_hls_cache_stream_registered( - app_state: &Arc, - fingerprint: &Fingerprint, - req_headers: &HeaderMap, - access: &HlsAccessContext, - session: &HlsSessionHandle, -) -> Option { - let (proxy_session_id, origin_source, origin_account_binding) = { - let session = session.read().await; - if session.is_gc_marked_for_removal() { - return None; - } - (session.proxy_session_id.clone(), session.origin_source.clone(), session.origin_account_binding.clone()) - }; - let user = app_state.app_config.get_user_credentials(&access.username)?; - let user_session = - app_state.active_users.get_and_update_user_session(&access.username, &access.user_session_token).await?; - let connection_kind = user_session.connection_kind?; - let priority = connection_priority_for_kind(&user, connection_kind); - let mut stream_channel = build_hls_cache_stream_channel(app_state, access, &origin_source, &proxy_session_id).await; - let provider = hls_cache_stats_provider(&origin_source, origin_account_binding.as_ref(), &user_session); - let user_agent = req_headers - .get(header::USER_AGENT) - .map_or_else(|| Cow::Borrowed(""), |value| String::from_utf8_lossy(value.as_bytes())); - - stream_channel.url = Arc::from(hls_cache_stream_stats_url(&proxy_session_id)); - // Panel Streams/History read this item_type. Shared HLS transport is still HLS, but - // archive/catchup leases must never be published as Live/LiveHls. - let panel_archive_reference = origin_source - .archive_reference - .or(access.epg_reference_ts) - .or_else(|| access.archive_origin_url.as_deref().and_then(m3u_archive_epg_reference_ts)) - .or_else(|| m3u_catchup_epg_reference_from_session_token(&access.user_session_token)) - .or(stream_channel.epg_reference_ts); - let is_archive_playback = panel_archive_reference.is_some() - || access.archive_origin_url.is_some() - || origin_source.archive_reference.is_some() - || is_m3u_catchup_session_token(&access.user_session_token) - || stream_channel.item_type == PlaylistItemType::Catchup; - if is_archive_playback { - stream_channel.item_type = PlaylistItemType::Catchup; - stream_channel.cluster = XtreamCluster::Video; - stream_channel.epg_reference_ts = panel_archive_reference; - } else { - stream_channel.item_type = PlaylistItemType::LiveHls; - stream_channel.cluster = PlaylistItemType::LiveHls.cluster(); - } - let shared_stream_id = hls_cache_shared_stream_id(&proxy_session_id); - stream_channel.shared = true; - stream_channel.shared_stream_id = Some(shared_stream_id); - stream_channel.shared_joined_existing = Some( - hls_cache_shared_joined_existing(app_state, shared_stream_id, &access.username, &access.user_session_token) - .await, - ); - let qos_config = HlsQosRuntimeConfig::from_app_config(&app_state.app_config); - let qos_registration = app_state - .hls_proxy - .qos() - .ensure_access_lease( - &access.lease_id, - &proxy_session_id, - current_time_millis(), - hls_qos_meter_init(app_state, qos_config), - ) - .await; - if let Some(meter) = qos_registration.register_meter.as_ref() { - app_state.event_manager.register_meter(Arc::clone(meter)).await; - } - let history_mode = if qos_registration.emit_connect_record { - ConnectionHistoryMode::EmitConnect - } else { - ConnectionHistoryMode::RefreshOnly - }; - - app_state - .connection_manager - .update_connection_with_history_mode( - crate::api::model::ConnectionParams { - meter_uid: qos_registration.meter_uid, - username: &access.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind, - priority, - soft_priority: user.soft_priority, - fingerprint, - provider, - stream_channel: &stream_channel, - user_agent, - session_token: Some(&access.user_session_token), - }, - history_mode, - ) - .await -} - -fn hls_cache_stats_provider( - origin_source: &HlsOriginSource, - origin_account_binding: Option<&HlsOriginAccountBinding>, - user_session: &UserSession, -) -> Arc { - origin_account_binding.filter(|binding| binding.is_active()).map_or_else( - || { - if user_session.provider.is_empty() { - Arc::clone(&origin_source.input_name) - } else { - Arc::clone(&user_session.provider) - } - }, - |binding| Arc::clone(&binding.account_name), - ) -} - -async fn build_hls_cache_stream_channel( - app_state: &Arc, - access: &HlsAccessContext, - origin_source: &HlsOriginSource, - proxy_session_id: &ProxySessionId, -) -> StreamChannel { - let mut channel = if let Some((_, target)) = app_state.app_config.get_target_for_username(&access.username) { - if let Some(mut channel) = get_stream_channel(app_state, &target, access.virtual_id).await { - channel.url = Arc::from(hls_cache_stream_stats_url(proxy_session_id)); - channel - } else { - fallback_hls_cache_stream_channel(target.id, access.virtual_id, origin_source, proxy_session_id) - } - } else { - fallback_hls_cache_stream_channel(0, access.virtual_id, origin_source, proxy_session_id) - }; - - let archive_reference = access - .epg_reference_ts - .or_else(|| access.archive_origin_url.as_deref().and_then(m3u_archive_epg_reference_ts)) - .or_else(|| m3u_catchup_epg_reference_from_session_token(&access.user_session_token)); - - if archive_reference.is_some() - || access.archive_origin_url.is_some() - || is_m3u_catchup_session_token(&access.user_session_token) - { - channel.item_type = PlaylistItemType::Catchup; - channel.cluster = XtreamCluster::Video; - channel.epg_reference_ts = archive_reference; - } else { - channel.item_type = PlaylistItemType::LiveHls; - channel.cluster = PlaylistItemType::LiveHls.cluster(); - channel.epg_reference_ts = None; - } - channel -} - -fn fallback_hls_cache_stream_channel( - target_id: u16, - virtual_id: u32, - origin_source: &HlsOriginSource, - proxy_session_id: &ProxySessionId, -) -> StreamChannel { - let unknown = "Unknown".intern(); - StreamChannel { - target_id, - virtual_id, - provider_id: 0, - input_name: Arc::clone(&origin_source.input_name), - item_type: PlaylistItemType::LiveHls, - cluster: XtreamCluster::Live, - group: unknown.clone(), - title: unknown, - url: Arc::from(hls_cache_stream_stats_url(proxy_session_id)), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - } -} - -fn hls_cache_stream_stats_url(proxy_session_id: &ProxySessionId) -> String { - format!("/hls/shared/live/{}/manifest.m3u8", proxy_session_id.0) -} - -fn hls_cache_shared_stream_id(proxy_session_id: &ProxySessionId) -> u64 { - let digest = Sha256::digest(proxy_session_id.0.as_bytes()); - digest.iter().take(8).fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)) -} - -async fn hls_cache_shared_joined_existing( - app_state: &Arc, - shared_stream_id: u64, - username: &str, - session_token: &str, -) -> bool { - let streams = app_state.active_users.active_streams().await; - if let Some(existing) = streams.iter().find(|stream| { - stream.username == username - && stream.session_token.as_deref() == Some(session_token) - && stream.channel.shared - && stream.channel.shared_stream_id == Some(shared_stream_id) - }) { - return existing.channel.shared_joined_existing.unwrap_or(false); - } - - streams.iter().any(|stream| { - stream.channel.shared - && stream.channel.shared_stream_id == Some(shared_stream_id) - && (stream.username != username || stream.session_token.as_deref() != Some(session_token)) - }) -} - -async fn validate_hls_proxy_access_context( - app_state: &Arc, - fingerprint: &Fingerprint, - proxy_session_id: &ProxySessionId, - hls_access_lease_id: &str, - now_ms: u64, - admission_mode: HlsAccessAdmissionMode, -) -> Result { - validate_hls_access_lease( - &app_state.hls_ctx(), - fingerprint, - proxy_session_id, - &HlsAccessLeaseId(hls_access_lease_id.to_string()), - now_ms, - admission_mode, - ) - .await -} - -async fn hls_custom_video_manifest_response_for_username( - app_state: &Arc, - username: &str, - video_type: CustomVideoStreamType, - fallback_status: StatusCode, -) -> axum::response::Response { - if let Some(user) = app_state.app_config.get_user_credentials(username) { - return hls_custom_video_manifest_response(app_state, &user, video_type, fallback_status).await; - } - fallback_status.into_response() -} - -async fn hls_custom_video_manifest_response_for_lease( - app_state: &Arc, - lease: &HlsAccessLease, - video_type: CustomVideoStreamType, - fallback_status: StatusCode, -) -> axum::response::Response { - let Some(user) = app_state.app_config.get_user_credentials(&lease.username) else { - return fallback_status.into_response(); - }; - hls_custom_video_manifest_response_for_access_lease(app_state, &user, video_type, fallback_status, lease).await -} - -async fn hls_runtime_custom_tail_response( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - reason: HlsRuntimeCustomTailReason, - fallback_status: StatusCode, -) -> axum::response::Response { - let outcome = commit_hls_runtime_custom_tail( - app_state.hls_ctx(), - HlsRuntimeCustomTailRequest { - session: Arc::clone(session), - proxy_session_id: proxy_session_id.clone(), - lease_id: access_lease_id.clone(), - reason, - now_ms: current_time_millis(), - }, - ) - .await; - if matches!(outcome, HlsRuntimeCustomTailOutcome::Committed | HlsRuntimeCustomTailOutcome::AlreadyCommitted) { - let now_ms = current_time_millis(); - if let Some(lease) = - app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await - { - if let Some(response) = hls_terminal_playback_response(&lease, proxy_session_id, access_lease_id) { - return response; - } - } - } - if outcome == HlsRuntimeCustomTailOutcome::PendingOwnerRegistered { - return hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS); - } - fallback_status.into_response() -} - -async fn hls_runtime_or_standalone_custom_tail_response( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - reason: HlsRuntimeCustomTailReason, - fallback_status: StatusCode, -) -> axum::response::Response { - let now_ms = current_time_millis(); - let Some(lease) = - app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await - else { - return fallback_status.into_response(); - }; - match &lease.playback_mode { - HlsLeasePlaybackMode::TerminalTail(_) | HlsLeasePlaybackMode::TerminalUnavailable { .. } => { - if let Some(response) = hls_terminal_playback_response(&lease, proxy_session_id, access_lease_id) { - return response; - } - } - HlsLeasePlaybackMode::Live - if lease.last_manifest_snapshot.is_some() - && matches!(lease.state, HlsAccessLeaseState::Activated | HlsAccessLeaseState::PolicyRevoking) => - { - return hls_runtime_custom_tail_response( - app_state, - session, - proxy_session_id, - access_lease_id, - reason, - fallback_status, - ) - .await; - } - HlsLeasePlaybackMode::Ended if !reason.permits_unpublished_lease_standalone_tail() => { - return fallback_status.into_response(); - } - HlsLeasePlaybackMode::Live | HlsLeasePlaybackMode::Ended => {} - } - hls_custom_video_manifest_response_for_lease(app_state, &lease, reason.video_type(), fallback_status).await -} - -async fn hls_manifest_channel_unavailable_response_for_username( - app_state: &Arc, - username: &str, -) -> axum::response::Response { - hls_custom_video_manifest_response_for_username( - app_state, - username, - CustomVideoStreamType::ChannelUnavailable, - StatusCode::NOT_FOUND, - ) - .await -} - -/// Resolves the final canonical-manifest fallback after refresh and cached-live -/// publication have both produced no response. -async fn hls_unpublished_lease_channel_unavailable_response( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, -) -> axum::response::Response { - let reason = HlsRuntimeCustomTailReason::ChannelUnavailable; - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(access_lease_id, proxy_session_id, current_time_millis()) - .await; - if let Some(lease) = lease.filter(|lease| lease.permits_unpublished_standalone_tail(reason)) { - return hls_custom_video_manifest_response_for_lease( - app_state, - &lease, - CustomVideoStreamType::ChannelUnavailable, - StatusCode::NOT_FOUND, - ) - .await; - } - StatusCode::SERVICE_UNAVAILABLE.into_response() -} - -async fn hls_manifest_access_denial_runtime_response( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_snapshot: Option<&HlsAccessLease>, - reason: HlsRuntimeCustomTailReason, - fallback_status: StatusCode, -) -> axum::response::Response { - let Some(lease) = lease_snapshot else { - return fallback_status.into_response(); - }; - let Some(session) = app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await else { - return fallback_status.into_response(); - }; - hls_runtime_or_standalone_custom_tail_response( - app_state, - &session, - proxy_session_id, - &lease.lease_id, - reason, - fallback_status, - ) - .await -} - -fn hls_resource_channel_unavailable_response( - _app_state: &Arc, - _access_context: &HlsAccessContext, -) -> axum::response::Response { - StatusCode::NOT_FOUND.into_response() -} - -fn hls_origin_runtime_resource_failure_response( - _app_state: &Arc, - _access_context: &HlsAccessContext, - err: HlsOriginRuntimeAcquireError, -) -> axum::response::Response { - match err { - HlsOriginRuntimeAcquireError::NoAccountAvailable { .. } => StatusCode::SERVICE_UNAVAILABLE.into_response(), - HlsOriginRuntimeAcquireError::Fatal(status) => hls_canonical_status_response(status), - } -} - -fn hls_resource_serve_outcome_response( - app_state: &Arc, - access_context: &HlsAccessContext, - outcome: HlsResourceServeOutcome, -) -> axum::response::Response { - match outcome { - HlsResourceServeOutcome::Ready(response) => response, - HlsResourceServeOutcome::Failure(HlsResourceServeFailure::TemporaryUnavailable { retry_after_ms }) => { - hls_temporary_resource_unavailable_response(retry_after_ms) - } - HlsResourceServeOutcome::Failure( - HlsResourceServeFailure::Missing - | HlsResourceServeFailure::Expired - | HlsResourceServeFailure::PermanentFailed { .. }, - ) => hls_resource_channel_unavailable_response(app_state, access_context), - } -} - -async fn hls_manifest_access_lease_validation_response( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_snapshot: Option<&HlsAccessLease>, - err: HlsAccessLeaseValidationError, -) -> axum::response::Response { - match err { - HlsAccessLeaseValidationError::AdmissionDenied { reason, .. } => { - hls_manifest_access_denial_runtime_response( - app_state, - proxy_session_id, - lease_snapshot, - reason.unwrap_or(HlsRuntimeCustomTailReason::UserConnectionsExhausted), - StatusCode::FORBIDDEN, - ) - .await - } - HlsAccessLeaseValidationError::AvailabilityPending => { - hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) - } - HlsAccessLeaseValidationError::UserSessionMissing { .. } => { - hls_manifest_access_denial_runtime_response( - app_state, - proxy_session_id, - lease_snapshot, - HlsRuntimeCustomTailReason::SessionOrLeaseExpired, - StatusCode::NOT_FOUND, - ) - .await - } - HlsAccessLeaseValidationError::UserAccountExpired { .. } => { - hls_manifest_access_denial_runtime_response( - app_state, - proxy_session_id, - lease_snapshot, - HlsRuntimeCustomTailReason::UserAccountExpired, - StatusCode::FORBIDDEN, - ) - .await - } - HlsAccessLeaseValidationError::Expired => StatusCode::NOT_FOUND.into_response(), - } -} - -fn hls_resource_access_lease_validation_response(err: &HlsAccessLeaseValidationError) -> axum::response::Response { - match err { - HlsAccessLeaseValidationError::AdmissionDenied { .. } - | HlsAccessLeaseValidationError::UserAccountExpired { .. } => StatusCode::FORBIDDEN.into_response(), - HlsAccessLeaseValidationError::AvailabilityPending => { - hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) - } - HlsAccessLeaseValidationError::UserSessionMissing { .. } | HlsAccessLeaseValidationError::Expired => { - StatusCode::NOT_FOUND.into_response() - } - } -} - -async fn hls_manifest_access_context_and_state( - app_state: &Arc, - fingerprint: &Fingerprint, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - access_lease_snapshot: Option<&HlsAccessLease>, - now_ms: u64, -) -> Result<(HlsAccessContext, HlsAccessLeaseState), Box> { - app_state.hls_proxy.startup_observability().record_media_manifest_request(access_lease_id, now_ms); - let access_context = match validate_hls_proxy_access_context( - app_state, - fingerprint, - proxy_session_id, - &access_lease_id.0, - now_ms, - HlsAccessAdmissionMode::ManifestPrepare, - ) - .await - { - Ok(context) => context, - Err(err) => { - warn!( - "HLS access lease rejected: lease={} proxy_session={} user_session=none reason={err:?}", - safe_hls_access_lease_id(access_lease_id), - safe_proxy_session_id(proxy_session_id) - ); - return Err(Box::new( - hls_manifest_access_lease_validation_response(app_state, proxy_session_id, access_lease_snapshot, err) - .await, - )); - } - }; - if access_lease_snapshot.is_none() - && app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await.is_none() - && app_state.hls_proxy.expired_session_marker(proxy_session_id, now_ms).await.is_some() - { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - } - debug!( - "HLS access lease accepted: lease={} proxy_session={} user_session={} request=manifest", - safe_hls_access_lease_id(&access_context.lease_id), - safe_proxy_session_id(proxy_session_id), - safe_user_session_token(&access_context.user_session_token) - ); - - let access_lease_state = match app_state - .hls_proxy - .touch_manifest_access_lease( - &access_context.lease_id, - proxy_session_id, - now_ms, - None, - Some(HlsAccessLeasePendingDeadline::Bootstrap { - deadline_ms: now_ms.saturating_add(hls_pending_bootstrap_window_ms(app_state)), - }), - hls_access_lease_ttl_ms(app_state), - ) - .await - { - HlsAccessLeaseTouch::Touched { lease } => lease.state, - HlsAccessLeaseTouch::Denied => { - return Err(Box::new( - hls_manifest_access_denial_runtime_response( - app_state, - proxy_session_id, - access_lease_snapshot, - HlsRuntimeCustomTailReason::UserConnectionsExhausted, - StatusCode::FORBIDDEN, - ) - .await, - )); - } - HlsAccessLeaseTouch::Expired | HlsAccessLeaseTouch::UnknownLease | HlsAccessLeaseTouch::SessionMismatch => { - return Err(Box::new(StatusCode::NOT_FOUND.into_response())); - } - }; - - Ok((access_context, access_lease_state)) -} - -async fn hls_transient_object_unavailable_response( - app_state: &Arc, - session: &HlsSessionHandle, - resource_file: &TransientResourceFile, - now_ms: u64, - access_context: &HlsAccessContext, -) -> axum::response::Response { - let state = { - let session = session.read().await; - let key = TransientPassthroughState::transient_object_key( - &session.proxy_session_id, - &resource_file.resource_id, - resource_file.extension.clone(), - ); - session.transient.object_unavailable_state(&key, now_ms) - }; - match state { - TransientObjectUnavailableState::Fetching => { - hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) - } - TransientObjectUnavailableState::FailedRetryable { retry_after_ms } => { - hls_temporary_resource_unavailable_response(retry_after_ms) - } - TransientObjectUnavailableState::FailedPermanent | TransientObjectUnavailableState::Missing => { - hls_resource_channel_unavailable_response(app_state, access_context) - } - } -} - -#[allow(clippy::too_many_lines)] -async fn fetch_and_cache_transient_origin_response( - context: HlsTransientEndpointCacheFetchContext<'_>, -) -> axum::response::Response { - let policy = context.app_state.hls_proxy.segment_fetch_policy(); - let mut fetch_finalizer = HlsTransientObjectFetchFinalizer::new( - Arc::clone(context.session), - Arc::clone(context.app_state.hls_proxy.segment_cache()), - context.fetch_token.clone(), - HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, - ); - let clients = HlsOriginResourceClients { - client: context.app_state.http_client.load().as_ref().clone(), - no_redirect_client: context.app_state.http_client_no_redirect.load().as_ref().clone(), - use_manual_redirects: context.app_state.should_use_manual_redirects(), - }; - let log_identity = { - let session = context.session.read().await; - HlsLogIdentity::from_session(&session) - }; - let fetch_request = HlsTransientOriginFetchRequest { - resolved_origin_uri: context.resource.resolved_origin_uri.clone(), - origin_headers: context.origin_headers.clone(), - origin_provider_session_headers: context.origin_provider_session_headers.clone(), - range_header: None, - resource_file: context.resource_file.clone(), - resource_kind: context.resource.kind, - clients, - policy: policy.clone(), - log_identity, - }; - let cache_fetch_request = HlsTransientOriginCacheFetchRequest { - fetch: fetch_request, - commit: HlsTransientCacheCommitContext { - segment_cache: Arc::clone(context.app_state.hls_proxy.segment_cache()), - segment_repair: Arc::clone(context.app_state.hls_proxy.segment_repair()), - session: Arc::clone(context.session), - access_lease_id: context.access_context.lease_id.clone(), - resource: context.resource.clone(), - resource_file: context.resource_file.clone(), - fetch_token: context.fetch_token.clone(), - cache_duration_ms: context.cache_duration_ms, - }, - }; - let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); - let prepare_attempt = hls_transient_origin_prepare_closure( - context.app_state, - context.session, - context.access_context, - context.fingerprint, - context.headers, - &runtime_prepare_error, - ); - let final_failure = - match fetch_and_commit_hls_transient_origin_response_with_attempt_prepare(cache_fetch_request, prepare_attempt) - .await - { - Ok(()) => { - let ready_at_ms = current_time_millis(); - let response_context = hls_cache_response_context( - context.app_state, - context.session, - context.access_context, - context.lease_identity, - ready_at_ms, - ) - .await; - let response = serve_hls_transient_object_cache_response( - Arc::clone(context.app_state.hls_proxy.segment_cache()), - Arc::clone(context.session), - context.resource_file.clone(), - context.range_header.clone(), - &response_context, - ) - .await; - if is_hls_media_activity_status(response.status()) { - register_hls_cache_stream_for_successful_media_response( - context.app_state, - context.fingerprint, - context.headers, - context.access_context, - context.session, - &response_context, - ) - .await; - } - record_successful_transient_segment_fetch(context.session, context.resource).await; - fetch_finalizer.complete(); - return response; - } - Err(err) => { - if matches!(err, HlsOriginResourceFetchError::ProviderUnavailable(_)) { - let runtime_prepare_error = *runtime_prepare_error.lock().await; - if let Some(runtime_err) = runtime_prepare_error { - context.session.write().await.fail_transient_object_retryable_if_current( - &context.fetch_token, - current_time_millis(), - HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, - ); - return hls_origin_runtime_resource_failure_response( - context.app_state, - context.access_context, - runtime_err, - ); - } - } - hls_transient_object_fetch_failure(&err) - } - }; - - let failed_at_ms = current_time_millis(); - match final_failure { - HlsTransientObjectFetchFailure::Retryable => { - if record_temporary_transient_segment_fetch_failure( - context.session, - context.resource, - &policy, - failed_at_ms, - ) - .await - { - context.session.write().await.fail_transient_object_permanent_if_current( - &context.fetch_token, - failed_at_ms, - None, - ); - } else { - context.session.write().await.fail_transient_object_retryable_if_current( - &context.fetch_token, - failed_at_ms, - HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, - ); - } - } - HlsTransientObjectFetchFailure::Permanent { status } => { - context.session.write().await.fail_transient_object_permanent_if_current( - &context.fetch_token, - failed_at_ms, - status, - ); - } - } - hls_transient_object_unavailable_response( - context.app_state, - context.session, - &context.resource_file, - failed_at_ms, - context.access_context, - ) - .await -} - -use tuliprox_core::utils::current_time_millis; - -async fn release_prepared_hls_manifest_session( - app_state: &Arc, - username: &str, - session_token: &str, - addr: &std::net::SocketAddr, -) { - let _transition_guard = app_state.active_users.acquire_playback_transition(username, session_token).await; - app_state.active_users.release_unbound_session_reservation(username, session_token, None, false).await; - app_state.active_users.clear_unbound_session_addr(username, session_token, addr).await; -} - -async fn terminate_failed_hls_manifest_session(app_state: &Arc, username: &str, session_token: &str) { - let _transition_guard = app_state.active_users.acquire_playback_transition(username, session_token).await; - app_state.active_users.terminate_session(username, session_token).await; - app_state.active_provider.clear_provider_reservation(session_token).await; -} - -fn normalize_xtream_live_hls_url(hls_url: &str, input: &ConfigInput) -> String { - if !input.input_type.is_xtream() || !input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix) { - return hls_url.to_string(); - } - - let (Some(username), Some(password)) = (input.username.as_deref(), input.password.as_deref()) else { - return hls_url.to_string(); - }; - - let Ok(mut parsed) = Url::parse(hls_url) else { - return hls_url.to_string(); - }; - let Some(segments) = parsed.path_segments() else { - return hls_url.to_string(); - }; - - let parts: Vec<&str> = segments.collect(); - if parts.len() >= 3 && parts[0] == username && parts[1] == password { - parsed.set_path(&format!("/live/{}", parts.join("/"))); - return parsed.to_string(); - } - - hls_url.to_string() -} - -fn ensure_hls_manifest_extension(url: &str) -> String { - let with_extension = replace_url_extension(url, HLS_EXT); - let (base_url, suffix) = match with_extension.find(['?', '#'].as_ref()) { - Some(pos) => (&with_extension[..pos], &with_extension[pos..]), - None => (with_extension.as_str(), ""), - }; - let Some(path_without_ext) = base_url.strip_suffix(HLS_EXT) else { - return with_extension; - }; - format!("{}{}{}", path_without_ext.trim_end_matches('.'), HLS_EXT, suffix) -} - -fn build_hls_manifest_request_headers( - input_headers: &HashMap, - req_headers: &HeaderMap, - disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, - default_user_agent: Option<&str>, - upstream_user_agent: Option<&str>, -) -> HeaderMap { - let input_headers = input_headers - .iter() - .filter(|(key, _)| !should_remove_hls_origin_header(key, disabled_headers)) - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - let disabled_headers_for_filter = disabled_headers.cloned(); - let filter_header: HeaderFilter = Some(Box::new(move |name: &str| { - !name.eq_ignore_ascii_case("range") - && !should_remove_hls_origin_header(name, disabled_headers_for_filter.as_ref()) - })); - let forwarded = get_headers_from_request(req_headers, &filter_header); - let mut headers = - request::get_request_headers(Some(&input_headers), Some(&forwarded), disabled_headers, default_user_agent); - request::overlay_upstream_user_agent(&mut headers, upstream_user_agent, disabled_headers); - scrub_hls_origin_headers(&mut headers, disabled_headers); - force_identity_without_range(&mut headers); - headers -} - -async fn download_legacy_hls_manifest( - app_state: &Arc, - input: &InputSource, - headers: &HeaderMap, -) -> Result<(String, String, HeaderMap), std::io::Error> { - let deadline = Duration::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms().max(1)); - let fetch_options = request::RequestFetchOptions::with_attempt_idle_timeout(deadline) - .with_content_coding(OutboundContentCodingPolicy::Identity); - let body_options = request::TextContentBodyOptions::hls_manifest(MAX_HLS_MANIFEST_BYTES, deadline); - let options = request::TextContentFetchOptions::new(fetch_options, body_options); - - if app_state.should_use_manual_redirects() { - request::download_text_content_with_manual_redirects_and_headers_and_options( - &app_state.app_config, - &app_state.http_client_no_redirect.load(), - input, - Some(headers), - false, - MAX_MANUAL_REDIRECTS, - options, - ) - .await - } else { - request::download_text_content_with_headers_and_options( - &app_state.app_config, - &app_state.http_client.load(), - input, - Some(headers), - false, - options, - ) - .await - } -} - -struct HlsCacheManifestOrigin<'a> { - raw_request_url: &'a str, - session_entry_url: HlsOriginEntryUrl, - input: &'a ConfigInput, - origin_source: HlsOriginSource, -} - -struct HlsCacheOriginResolution { - hls_url: String, - session_entry_url: HlsOriginEntryUrl, -} - -#[derive(Clone, Debug)] -enum HlsOriginEntryUrl { - DirectHttp { url: String }, - ProviderFailover { url: String, provider: Arc }, -} - -impl HlsOriginEntryUrl { - fn direct_http(url: impl Into) -> Self { Self::DirectHttp { url: url.into() } } - - fn provider_failover(url: impl Into, provider: Arc) -> Self { - Self::ProviderFailover { url: url.into(), provider } - } - - fn as_str(&self) -> &str { - match self { - Self::DirectHttp { url } | Self::ProviderFailover { url, .. } => url, - } - } - - fn url_failover_provider(&self) -> Option> { - match self { - Self::DirectHttp { .. } => None, - Self::ProviderFailover { provider, .. } => Some(Arc::clone(provider)), - } - } -} - -fn resolve_hls_cache_origin_entry_url(input: &ConfigInput, url: &str) -> Option { - if let Some(provider) = hls_url_failover_provider_for_origin_url(input, url) { - return Some(HlsCacheOriginResolution { - hls_url: url.to_string(), - session_entry_url: HlsOriginEntryUrl::provider_failover(url, provider), - }); - } - - let parsed = Url::parse(url).ok()?; - if matches!(parsed.scheme(), "http" | "https") { - return Some(HlsCacheOriginResolution { - hls_url: url.to_string(), - session_entry_url: HlsOriginEntryUrl::direct_http(url), - }); - } - - warn!("HLS origin entry URL is not supported: url={}", sanitize_sensitive_info(url)); - None -} - -fn hls_url_failover_provider_for_origin_url(input: &ConfigInput, url: &str) -> Option> { - if !url.starts_with(PROVIDER_SCHEME_PREFIX) { - return None; - } - input.get_resolve_provider(url).map(|provider| Arc::clone(&provider)) -} - -fn is_http_hls_origin_url(url: &str) -> bool { - Url::parse(url).is_ok_and(|parsed| matches!(parsed.scheme(), "http" | "https")) -} - -fn is_supported_hls_origin_url(input: &ConfigInput, url: &str) -> bool { - input.get_resolve_provider(url).is_some() || is_http_hls_origin_url(url) -} - -fn build_hls_origin_source(input: &ConfigInput, stream_ref: impl Into) -> HlsOriginSource { - HlsOriginSource::new(input.id, Arc::clone(&input.name), stream_ref, hls_origin_source_kind(input.input_type)) -} - -fn build_hls_origin_source_for_playback( - input: &ConfigInput, - stream_ref: impl Into, - archive_reference: Option, - archive_url: Option<&str>, -) -> HlsOriginSource { - let source = build_hls_origin_source(input, stream_ref); - match (archive_reference, archive_url) { - (Some(timestamp), Some(url)) => source.with_archive_request(timestamp, url), - (Some(timestamp), None) => source.with_archive_reference(timestamp), - (None, _) => source, - } -} - -/// Keeps target routing identity separate from the immutable input content identity. -#[derive(Debug, Clone, Eq, PartialEq)] -pub(in crate::api) struct HlsEntryStreamIdentity { - virtual_id: u32, - input_stream_id: Arc, - upstream_user_agent: Option>, -} - -impl HlsEntryStreamIdentity { - pub(in crate::api) fn new(virtual_id: u32, input_stream_id: impl Into>) -> Option { - let input_stream_id = input_stream_id.into(); - if input_stream_id.trim().is_empty() { - return None; - } - Some(Self { virtual_id, input_stream_id, upstream_user_agent: None }) - } - - pub(in crate::api) fn from_playlist_item(item: &impl PlaylistEntry) -> Option { - let mut identity = Self::new(item.get_virtual_id().get(), item.get_input_stream_id()?)?; - identity.upstream_user_agent = item.get_upstream_user_agent().map(Internable::intern); - Some(identity) - } - - pub(in crate::api) const fn virtual_id(&self) -> u32 { self.virtual_id } - - fn stream_ref(&self) -> &str { self.input_stream_id.as_ref() } - - fn upstream_user_agent(&self) -> Option<&str> { self.upstream_user_agent.as_deref() } -} - -/// Immutable input identity plus bitrate metadata available at the virtual HLS entry. -#[derive(Debug, Clone, Eq, PartialEq)] -pub(in crate::api) struct HlsEntryStreamContext { - identity: HlsEntryStreamIdentity, - known_bitrate_bps: Option, -} - -impl HlsEntryStreamContext { - pub(in crate::api) fn from_playlist_item(item: &impl PlaylistEntry) -> Option { - let identity = HlsEntryStreamIdentity::from_playlist_item(item)?; - let known_bitrate_bps = match item.get_additional_properties() { - Some(StreamProperties::Live(properties)) if properties.bitrate > 0 => Some(properties.bitrate), - Some( - StreamProperties::Live(_) - | StreamProperties::Video(_) - | StreamProperties::Series(_) - | StreamProperties::Episode(_), - ) - | None => None, - }; - Some(Self { identity, known_bitrate_bps }) - } - - pub(in crate::api) const fn virtual_id(&self) -> u32 { self.identity.virtual_id() } - - pub(in crate::api) fn stream_ref(&self) -> &str { self.identity.stream_ref() } - - pub(in crate::api) const fn known_bitrate_bps(&self) -> Option { self.known_bitrate_bps } - - pub(in crate::api) fn identity(&self) -> &HlsEntryStreamIdentity { &self.identity } -} - -/// Resolves the configured input together with both identities of one target entry. -#[derive(Debug, Clone)] -pub(in crate::api) struct HlsResolvedVirtualSource { - pub(in crate::api) input: Arc, - pub(in crate::api) stream_context: HlsEntryStreamContext, -} - -fn hls_origin_source_kind(input_type: InputType) -> HlsOriginSourceKind { - if input_type.is_xtream() { - HlsOriginSourceKind::XtreamLive - } else if input_type.is_m3u() { - HlsOriginSourceKind::M3uMediaPlaylist - } else { - HlsOriginSourceKind::DirectMediaPlaylist - } -} - -fn build_hls_origin_resolution(input: &ConfigInput, media_playlist_url: &str) -> Option { - let candidate = match hls_origin_source_kind(input.input_type) { - HlsOriginSourceKind::XtreamLive => { - ensure_hls_manifest_extension(&normalize_xtream_live_hls_url(media_playlist_url, input)) - } - HlsOriginSourceKind::M3uMediaPlaylist | HlsOriginSourceKind::DirectMediaPlaylist => { - ensure_hls_manifest_extension(media_playlist_url) - } - }; - resolve_hls_cache_origin_entry_url(input, &candidate) -} - -#[derive(Clone, Copy)] -enum HlsOriginWorkKind { - Manifest, - Segment, - Resource, -} - -impl HlsOriginWorkKind { - const fn as_log_value(self) -> &'static str { - match self { - Self::Manifest => "manifest", - Self::Segment => "segment", - Self::Resource => "resource", - } - } -} - -fn build_hls_origin_fetch_url( - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - provider_config: Option<&Arc>, -) -> Option { - let provider_scheme_url = [session_entry_url, raw_request_url] - .into_iter() - .find(|url| hls_url_failover_provider_for_origin_url(input, url).is_some()); - let url = if let (Some(provider_config), Some(provider_scheme_url)) = (provider_config, provider_scheme_url) { - rewrite_hls_provider_scheme_origin_account(provider_scheme_url, input, provider_config)? - } else if let Some(provider_config) = provider_config { - get_stream_alternative_url(raw_request_url, input, provider_config) - .or_else(|| get_stream_alternative_url(session_entry_url, input, provider_config)) - .unwrap_or_else(|| session_entry_url.to_string()) - } else { - session_entry_url.to_string() - }; - - if is_supported_hls_origin_url(input, &url) { - Some(url) - } else { - None - } -} - -fn rewrite_hls_provider_scheme_origin_account( - provider_scheme_url: &str, - input: &ConfigInput, - provider_config: &Arc, -) -> Option { - if !provider_scheme_url.starts_with(PROVIDER_SCHEME_PREFIX) { - return None; - } - let alt_input_user_info = provider_config.get_user_info()?; - let Some((_source_base_url, source_username, source_password)) = - input.get_matched_config_by_url(provider_scheme_url) - else { - return Some(provider_scheme_url.to_string()); - }; - let (Some(old_username), Some(old_password)) = (source_username, source_password) else { - return Some(provider_scheme_url.to_string()); - }; - - let mut url = Url::parse(provider_scheme_url).ok()?; - if rewrite_hls_url_auth_fields( - &mut url, - old_username, - old_password, - &alt_input_user_info.username, - &alt_input_user_info.password, - ) { - Some(url.to_string()) - } else { - None - } -} - -fn rewrite_hls_url_auth_fields( - url: &mut Url, - old_username: &str, - old_password: &str, - new_username: &str, - new_password: &str, -) -> bool { - if rewrite_hls_query_auth_fields(url, new_username, new_password) { - return true; - } - - if url.username() == old_username && url.password() == Some(old_password) { - return url.set_username(new_username).is_ok() && url.set_password(Some(new_password)).is_ok(); - } - - rewrite_hls_path_auth_fields(url, old_username, old_password, new_username, new_password) -} - -fn rewrite_hls_query_auth_fields(url: &mut Url, new_username: &str, new_password: &str) -> bool { - let mut has_username = false; - let mut has_password = false; - let pairs: Vec<(String, String)> = url - .query_pairs() - .map(|(key, value)| { - if key.eq_ignore_ascii_case("username") { - has_username = true; - (key.into_owned(), new_username.to_string()) - } else if key.eq_ignore_ascii_case("password") { - has_password = true; - (key.into_owned(), new_password.to_string()) - } else { - (key.into_owned(), value.into_owned()) - } - }) - .collect(); - - if !(has_username && has_password) { - return false; - } - - url.query_pairs_mut().clear().extend_pairs(pairs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); - true -} - -fn rewrite_hls_path_auth_fields( - url: &mut Url, - old_username: &str, - old_password: &str, - new_username: &str, - new_password: &str, -) -> bool { - let Some(mut segments) = url.path_segments().map(|segments| segments.map(ToOwned::to_owned).collect::>()) - 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 Some(credential_index) = credential_index else { - return false; - }; - - segments[credential_index] = new_username.to_string(); - segments[credential_index + 1] = new_password.to_string(); - - let Ok(mut path_segments) = url.path_segments_mut() else { - return false; - }; - path_segments.clear().extend(segments.iter().map(String::as_str)); - true -} - -fn hls_url_failover_provider_for_origin_context( - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - fetch_url: &str, -) -> Option> { - hls_url_failover_provider_for_origin_url(input, session_entry_url) - .or_else(|| hls_url_failover_provider_for_origin_url(input, raw_request_url)) - .or_else(|| hls_url_failover_provider_for_origin_url(input, fetch_url)) -} - -struct PreparedHlsOriginRuntime { - fetch_url: String, - // URL failover comes from source.yml provider:// resolution. Origin-account - // binding/handles are runtime account reservations and must stay separate. - url_failover_provider: Option>, - origin_account_binding_to_store: Option, - preacquired_origin_account_handle: Option, -} - -fn effective_hls_url_failover_provider_for_fetch_url( - fetch_url: &str, - prepared_url_failover_provider: Option>, - origin_url_failover_provider: Option>, -) -> Option> { - if !fetch_url.starts_with(PROVIDER_SCHEME_PREFIX) { - return None; - } - prepared_url_failover_provider.or(origin_url_failover_provider) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HlsOriginRuntimeAcquireError { - NoAccountAvailable { reason: HlsOriginRuntimeNoAccountReason }, - Fatal(StatusCode), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HlsOriginRuntimeNoAccountReason { - ProviderConnectionsExhausted, - OriginBindingPreempted, -} - -fn hls_no_account_reason_for_binding(binding: Option<&HlsOriginAccountBinding>) -> HlsOriginRuntimeNoAccountReason { - let Some(binding) = binding else { - return HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted; - }; - match &binding.binding_mode { - HlsOriginAccountBindingMode::Detached { - reason: HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, - .. - } - | HlsOriginAccountBindingMode::Detached { - reason: HlsOriginAccountDetachedReason::PreemptedByHigherPriority, - .. - } => HlsOriginRuntimeNoAccountReason::OriginBindingPreempted, - HlsOriginAccountBindingMode::Detached { .. } - | HlsOriginAccountBindingMode::Active - | HlsOriginAccountBindingMode::Speculative { .. } => { - HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted - } - } -} - -#[derive(Clone)] -struct HlsAccountOverlapCandidate { - proxy_session_id: ProxySessionId, - input_name: Arc, - account_name: Arc, - session_owner: String, - reclaim_until_ms: u64, - last_media_at_ms: u64, - soft_overlap_eligible_at_ms: u64, - soft_overlap_delay_ms: u64, - tuliprox_target_user_connection_capacity: u32, - origin_input_account_connection_capacity: u32, -} - -#[derive(Clone)] -struct HlsOriginPolicyPreemptCandidate { - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - account_name: Arc, - session_owner: String, - reservation_ttl_secs: u64, - victim_policy: HlsEffectiveOriginAcquirePolicy, - last_media_at_ms: u64, -} - -#[derive(Debug, Clone, Copy)] -struct HlsSoftOverlapCapacity { - tuliprox_target_user_connection_capacity: u32, - origin_input_account_connection_capacity: u32, - delay_ms: u64, -} - -fn hls_soft_overlap_capacity_for_target_duration( - tuliprox_target_user_connection_capacity: u32, - origin_input_account_connection_capacity: u32, - target_duration_ms: u64, -) -> HlsSoftOverlapCapacity { - let delay_ms = hls_soft_overlap_delay_ms( - target_duration_ms, - tuliprox_target_user_connection_capacity, - origin_input_account_connection_capacity, - ); - HlsSoftOverlapCapacity { - tuliprox_target_user_connection_capacity, - origin_input_account_connection_capacity, - delay_ms, - } -} - -async fn hls_origin_input_account_connection_capacity(app_state: &Arc, input: &ConfigInput) -> u32 { - let capacities = app_state.active_provider.provider_capacities_for_input(&input.name).await; - if capacities.is_empty() { - return hls_configured_origin_input_account_connection_capacity(input); - } - capacities - .into_iter() - .map(|(_, _, max)| if max == 0 { u32::MAX } else { u32::try_from(max).unwrap_or(u32::MAX) }) - .fold(0u32, u32::saturating_add) - .max(1) -} - -fn hls_configured_origin_input_account_connection_capacity(input: &ConfigInput) -> u32 { - let input_capacity = if input.max_connections == 0 { 1 } else { u32::from(input.max_connections) }; - input - .aliases - .as_ref() - .map_or(0, |aliases| { - aliases - .iter() - .filter(|alias| alias.enabled) - .map(|alias| if alias.max_connections == 0 { 1 } else { u32::from(alias.max_connections) }) - .fold(0u32, u32::saturating_add) - }) - .saturating_add(input_capacity) - .max(1) -} - -async fn hls_tuliprox_target_user_connection_capacity(app_state: &Arc, input: &ConfigInput) -> u32 { - hls_configured_tuliprox_target_user_connection_capacity(app_state, input) - .max(hls_active_tuliprox_target_user_connections_for_input(app_state, input).await) - .max(1) -} - -fn hls_configured_tuliprox_target_user_connection_capacity(app_state: &Arc, input: &ConfigInput) -> u32 { - let Some(api_proxy) = app_state.app_config.api_proxy.load().as_ref().cloned() else { - return 1; - }; - api_proxy - .user - .iter() - .filter(|target_user| { - app_state - .app_config - .get_inputs_for_target(&target_user.target) - .is_some_and(|inputs| inputs.iter().any(|candidate| candidate.name == input.name)) - }) - .map(|target_user| { - target_user - .credentials - .iter() - .map(|user| { - if user.max_connections == 0 { - u32::MAX - } else { - user.max_connections.saturating_add(u32::from(user.soft_connections)) - } - }) - .fold(0u32, u32::saturating_add) - }) - .max() - .unwrap_or(1) - .max(1) -} - -async fn hls_active_tuliprox_target_user_connections_for_input(app_state: &Arc, input: &ConfigInput) -> u32 { - u32::try_from( - app_state - .active_users - .active_streams() - .await - .iter() - .filter(|stream| stream.channel.input_name == input.name) - .count(), - ) - .unwrap_or(u32::MAX) -} - -fn hls_soft_overlap_delay_ms( - target_duration_ms: u64, - tuliprox_target_user_connection_capacity: u32, - origin_input_account_connection_capacity: u32, -) -> u64 { - let target_duration_ms = target_duration_ms.max(1); - let users = u64::from(tuliprox_target_user_connection_capacity.max(1)); - let origin = u64::from(origin_input_account_connection_capacity.max(1)); - if users >= origin.saturating_mul(2) { - return target_duration_ms; - } - if users <= origin { - return target_duration_ms.saturating_mul(2); - } - let numerator = origin.saturating_mul(3).saturating_sub(users); - target_duration_ms.saturating_mul(numerator).saturating_add(origin.saturating_sub(1)) / origin -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn prepare_hls_origin_runtime( - app_state: &Arc, - session: &HlsSessionHandle, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - proxy_session_id: &ProxySessionId, - fingerprint: &Fingerprint, - connection_kind: crate::api::model::ConnectionKind, - priority: i8, - work_kind: HlsOriginWorkKind, - work_class: HlsOriginWorkClass, - now_ms: u64, -) -> Result { - promote_elapsed_hls_account_overlaps(app_state, now_ms).await; - detach_unprotected_hls_origin_account_bindings(app_state, now_ms).await; - reclaim_hls_account_overlap_if_needed(app_state, session, now_ms).await; - let existing_binding = session.read().await.origin_account_binding.clone(); - let reacquire_detached_binding = existing_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_detached); - let final_no_account_reason = hls_no_account_reason_for_binding(existing_binding.as_ref()); - if reacquire_detached_binding { - log_hls_origin_binding_reacquire_started(session, work_kind).await; - } - if let Some(binding) = existing_binding { - if binding.is_active() { - match hls_origin_account_status(&app_state.hls_ctx(), &binding) { - stale_status @ (HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired) => { - return rebind_hls_origin_account( - app_state, - session, - input, - raw_request_url, - session_entry_url, - &binding, - stale_status, - fingerprint, - connection_kind, - priority, - now_ms, - ) - .await; - } - HlsOriginAccountStatus::Known => { - return Ok(prepared_hls_origin_runtime_for_known_binding( - app_state, - input, - raw_request_url, - session_entry_url, - &binding, - )); - } - } - } - } - - match prepare_hls_origin_runtime_with_new_account( - app_state, - input, - raw_request_url, - session_entry_url, - proxy_session_id, - fingerprint, - connection_kind, - priority, - false, - work_kind, - work_class, - now_ms, - ) - .await - { - Ok(prepared) => { - if reacquire_detached_binding { - if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { - log_hls_origin_binding_reacquired(session, binding).await; - } - } - return Ok(prepared); - } - Err(HlsOriginRuntimeAcquireError::Fatal(status)) => return Err(HlsOriginRuntimeAcquireError::Fatal(status)), - Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { .. }) => {} - } - - if work_class.allows_speculative_overlap() { - if let Ok(prepared) = prepare_hls_origin_policy_preempt_runtime( - app_state, - session, - input, - raw_request_url, - session_entry_url, - proxy_session_id, - fingerprint, - connection_kind, - priority, - now_ms, - ) - .await - { - if reacquire_detached_binding { - if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { - log_hls_origin_binding_reacquired(session, binding).await; - } - } - return Ok(prepared); - } - - if let Ok(prepared) = prepare_hls_speculative_origin_runtime( - app_state, - session, - input, - raw_request_url, - session_entry_url, - proxy_session_id, - fingerprint, - connection_kind, - priority, - now_ms, - ) - .await - { - if reacquire_detached_binding { - if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { - log_hls_origin_binding_reacquired(session, binding).await; - } - } - return Ok(prepared); - } - } else { - debug!("HLS account overlap skipped: work_class={} reason=background-origin-work", work_class.as_log_value()); - } - - if work_class.allows_grace() { - match prepare_hls_origin_runtime_with_new_account( - app_state, - input, - raw_request_url, - session_entry_url, - proxy_session_id, - fingerprint, - connection_kind, - priority, - true, - work_kind, - work_class, - now_ms, - ) - .await - { - Ok(prepared) => { - if reacquire_detached_binding { - if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { - log_hls_origin_binding_reacquired(session, binding).await; - } - } - return Ok(prepared); - } - Err(HlsOriginRuntimeAcquireError::Fatal(status)) => { - return Err(HlsOriginRuntimeAcquireError::Fatal(status)) - } - Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { .. }) => {} - } - } else { - debug!( - "HLS origin account grace skipped: work_class={} reason=background-origin-work", - work_class.as_log_value() - ); - } - - if reacquire_detached_binding { - log_hls_origin_binding_reacquire_failed(session, "no-account-available").await; - } - Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { reason: final_no_account_reason }) -} - -#[allow(clippy::too_many_arguments)] -async fn prepare_hls_origin_runtime_with_new_account( - app_state: &Arc, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - proxy_session_id: &ProxySessionId, - fingerprint: &Fingerprint, - connection_kind: crate::api::model::ConnectionKind, - priority: i8, - allow_grace: bool, - work_kind: HlsOriginWorkKind, - work_class: HlsOriginWorkClass, - now_ms: u64, -) -> Result { - let session_owner = build_hls_origin_session_owner(proxy_session_id); - let Some(provider_handle) = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &fingerprint.addr, - allow_grace, - priority, - connection_kind, - Some(&session_owner), - ) - .await - else { - debug!( - "HLS origin account acquire unavailable: work={} work_class={} grace={}", - work_kind.as_log_value(), - work_class.as_log_value(), - if allow_grace { "attempted" } else { "disabled" } - ); - return Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { - reason: HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted, - }); - }; - - let Some(provider_config) = provider_handle.allocation.get_provider_config() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) - else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let Some(binding) = origin_account_binding_from_allocation( - Arc::clone(&input.name), - proxy_session_id, - &provider_handle.allocation, - now_ms, - ) else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let grace_state = if matches!(provider_handle.allocation, ProviderAllocation::GracePeriod(_)) { - "granted" - } else if allow_grace { - "not-needed" - } else { - "disabled" - }; - debug!( - "HLS origin account binding created: account={} owner={} work={} work_class={} grace={}", - sanitize_sensitive_info(binding.account_name.as_ref()), - sanitize_sensitive_info(&binding.session_owner), - work_kind.as_log_value(), - work_class.as_log_value(), - grace_state - ); - - Ok(PreparedHlsOriginRuntime { - url_failover_provider: hls_url_failover_provider_for_origin_context( - input, - raw_request_url, - session_entry_url, - &fetch_url, - ), - fetch_url, - origin_account_binding_to_store: Some(binding), - preacquired_origin_account_handle: Some(provider_handle), - }) -} - -fn prepared_hls_origin_runtime_for_known_binding( - app_state: &Arc, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - binding: &HlsOriginAccountBinding, -) -> PreparedHlsOriginRuntime { - let fetch_url = app_state - .active_provider - .find_provider_config(&binding.account_name) - .as_ref() - .and_then(|provider_config| { - build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(provider_config)) - }) - .unwrap_or_else(|| session_entry_url.to_string()); - - PreparedHlsOriginRuntime { - url_failover_provider: hls_url_failover_provider_for_origin_context( - input, - raw_request_url, - session_entry_url, - &fetch_url, - ), - fetch_url, - origin_account_binding_to_store: None, - preacquired_origin_account_handle: None, - } -} - -async fn log_hls_origin_binding_reacquire_started(session: &HlsSessionHandle, work_kind: HlsOriginWorkKind) { - let session_guard = session.read().await; - let mode = match session_guard.mode { - HlsSessionMode::NormalCacheTimeline => "normal", - HlsSessionMode::TransientPassthrough { .. } => "transient", - }; - debug!( - "HLS origin binding reacquire started: proxy_session={} mode={} work={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - mode, - work_kind.as_log_value() - ); -} - -async fn log_hls_origin_binding_reacquired(session: &HlsSessionHandle, binding: &HlsOriginAccountBinding) { - let session_guard = session.read().await; - debug!( - "HLS origin binding reacquired: proxy_session={} account={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(binding.account_name.as_ref()) - ); -} - -async fn log_hls_origin_binding_reacquire_failed(session: &HlsSessionHandle, reason: &str) { - let session_guard = session.read().await; - debug!( - "HLS origin binding reacquire failed: proxy_session={} reason={} retry_after_ms={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - reason, - cold_start_retry_after_seconds().saturating_mul(1_000) - ); -} - -async fn detach_unprotected_hls_origin_account_bindings(app_state: &Arc, now_ms: u64) { - let sessions = app_state.hls_proxy.sessions().list_sessions().await; - for session in sessions { - let binding = { - let mut session_guard = session.write().await; - let Some(binding) = session_guard.origin_account_binding.clone() else { - continue; - }; - if !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { - continue; - } - let timing = session_guard.account_overlap_timing(); - let protection = session_guard.account_binding_protection(now_ms); - debug!( - "HLS account protection classified: proxy_session={} state={} target_duration_ms={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - protection.as_log_state(), - timing.target_duration_ms - ); - if !matches!(protection, HlsAccountBindingProtection::Expired) - || session_guard.activity.active_origin_work_count > 0 - { - continue; - } - if !matches!(hls_origin_account_status(&app_state.hls_ctx(), &binding), HlsOriginAccountStatus::Known) { - continue; - } - if let Some(binding) = session_guard.origin_account_binding.as_mut() { - binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, now_ms); - } - session_guard.invalidate_queued_origin_work(); - debug!( - "HLS origin binding detached: proxy_session={} account={} reason={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(binding.account_name.as_ref()), - HlsOriginAccountDetachedReason::SoftWindowElapsed.as_log_reason() - ); - binding - }; - app_state.active_provider.clear_provider_reservation(&binding.session_owner).await; - } -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn prepare_hls_origin_policy_preempt_runtime( - app_state: &Arc, - new_session: &HlsSessionHandle, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - proxy_session_id: &ProxySessionId, - fingerprint: &Fingerprint, - connection_kind: crate::api::model::ConnectionKind, - priority: i8, - now_ms: u64, -) -> Result { - let request_policy = HlsEffectiveOriginAcquirePolicy::new(connection_kind, priority, now_ms); - let Some(candidate) = - find_hls_origin_policy_preempt_candidate(app_state, input, proxy_session_id, request_policy, now_ms).await - else { - debug!("HLS origin policy preemption denied: reason=no-lower-origin-policy-candidate"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - app_state.active_provider.clear_provider_reservation(&candidate.session_owner).await; - let session_owner = build_hls_origin_session_owner(proxy_session_id); - let Some(provider_handle) = app_state - .active_provider - .acquire_exact_connection_with_grace_for_session( - &candidate.account_name, - &fingerprint.addr, - false, - priority, - connection_kind, - Some(&session_owner), - ) - .await - else { - restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; - debug!("HLS origin policy preemption denied: reason=exact-acquire-failed"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let Some(provider_config) = provider_handle.allocation.get_provider_config() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; - debug!("HLS origin policy preemption denied: reason=missing-provider-config"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) - else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; - debug!("HLS origin policy preemption denied: reason=invalid-origin-url"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let Some(binding) = origin_account_binding_from_allocation( - Arc::clone(&input.name), - proxy_session_id, - &provider_handle.allocation, - now_ms, - ) else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; - debug!("HLS origin policy preemption denied: reason=invalid-allocation"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let mut detached_victim = false; - { - let mut victim = candidate.session.write().await; - if let Some(victim_binding) = victim.origin_account_binding.as_mut() { - if victim_binding.account_name == candidate.account_name - && victim_binding.session_owner == candidate.session_owner - && matches!(victim_binding.binding_mode, HlsOriginAccountBindingMode::Active) - { - victim_binding.detach(HlsOriginAccountDetachedReason::PreemptedByHigherPriority, now_ms); - detached_victim = true; - } - } - if detached_victim { - victim.invalidate_queued_origin_work(); - } - } - if !detached_victim { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; - debug!("HLS origin policy preemption denied: reason=stale-candidate"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - } - - { - let mut session_guard = new_session.write().await; - session_guard.replace_origin_account_binding(Some(binding.clone())); - } - debug!( - "HLS origin policy preempted: account={} victim_proxy_session={} winner_proxy_session={} victim_kind={:?} victim_priority={} request_kind={:?} request_priority={}", - sanitize_sensitive_info(candidate.account_name.as_ref()), - safe_proxy_session_id(&candidate.proxy_session_id), - safe_proxy_session_id(proxy_session_id), - candidate.victim_policy.connection_kind, - candidate.victim_policy.priority, - request_policy.connection_kind, - request_policy.priority - ); - debug!( - "HLS origin binding detached: proxy_session={} account={} reason={}", - safe_proxy_session_id(&candidate.proxy_session_id), - sanitize_sensitive_info(candidate.account_name.as_ref()), - HlsOriginAccountDetachedReason::PreemptedByHigherPriority.as_log_reason() - ); - - Ok(PreparedHlsOriginRuntime { - url_failover_provider: hls_url_failover_provider_for_origin_context( - input, - raw_request_url, - session_entry_url, - &fetch_url, - ), - fetch_url, - origin_account_binding_to_store: Some(binding), - preacquired_origin_account_handle: Some(provider_handle), - }) -} - -async fn restore_hls_origin_policy_preempt_candidate_reservation( - app_state: &Arc, - candidate: &HlsOriginPolicyPreemptCandidate, -) { - app_state - .active_provider - .refresh_provider_reservation(&candidate.account_name, &candidate.session_owner, candidate.reservation_ttl_secs) - .await; -} - -async fn find_hls_origin_policy_preempt_candidate( - app_state: &Arc, - input: &ConfigInput, - new_proxy_session_id: &ProxySessionId, - request_policy: HlsEffectiveOriginAcquirePolicy, - _now_ms: u64, -) -> Option { - let sessions = app_state.hls_proxy.sessions().list_sessions().await; - let mut best_candidate = None; - for session in sessions { - let session_guard = session.read().await; - if session_guard.proxy_session_id == *new_proxy_session_id { - continue; - } - let Some(binding) = session_guard.origin_account_binding.as_ref() else { - continue; - }; - if binding.input_name != input.name || !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { - continue; - } - if session_guard.activity.active_origin_work_count > 0 { - continue; - } - if !matches!(hls_origin_account_status(&app_state.hls_ctx(), binding), HlsOriginAccountStatus::Known) { - continue; - } - let victim_policy = session_guard.effective_origin_acquire_policy_or_default(); - if !request_policy.is_better_than(victim_policy) { - continue; - } - let candidate = HlsOriginPolicyPreemptCandidate { - session: Arc::clone(&session), - proxy_session_id: session_guard.proxy_session_id.clone(), - account_name: Arc::clone(&binding.account_name), - session_owner: binding.session_owner.clone(), - reservation_ttl_secs: session_guard.account_overlap_timing().reservation_ttl_secs(), - victim_policy, - last_media_at_ms: session_guard.activity.last_authorized_media_at_ms.unwrap_or_default(), - }; - if hls_origin_policy_preempt_candidate_is_better(best_candidate.as_ref(), &candidate) { - best_candidate = Some(candidate); - } - } - best_candidate -} - -fn hls_origin_policy_preempt_candidate_is_better( - current: Option<&HlsOriginPolicyPreemptCandidate>, - candidate: &HlsOriginPolicyPreemptCandidate, -) -> bool { - let Some(current) = current else { - return true; - }; - match (candidate.victim_policy.connection_kind, current.victim_policy.connection_kind) { - (crate::api::model::ConnectionKind::Soft, crate::api::model::ConnectionKind::Normal) => return true, - (crate::api::model::ConnectionKind::Normal, crate::api::model::ConnectionKind::Soft) => return false, - _ => {} - } - candidate.victim_policy.priority > current.victim_policy.priority - || (candidate.victim_policy.priority == current.victim_policy.priority - && candidate.last_media_at_ms < current.last_media_at_ms) -} - -#[allow(clippy::too_many_arguments)] -async fn prepare_hls_speculative_origin_runtime( - app_state: &Arc, - new_session: &HlsSessionHandle, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - proxy_session_id: &ProxySessionId, - fingerprint: &Fingerprint, - connection_kind: crate::api::model::ConnectionKind, - priority: i8, - now_ms: u64, -) -> Result { - let Some(candidate) = find_hls_account_overlap_candidate(app_state, input, proxy_session_id, now_ms).await else { - debug!("HLS account overlap denied: reason=no-soft-active-candidate"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - app_state.active_provider.clear_provider_reservation(&candidate.session_owner).await; - let session_owner = build_hls_origin_session_owner(proxy_session_id); - let Some(provider_handle) = app_state - .active_provider - .acquire_exact_connection_with_grace_for_session( - &candidate.account_name, - &fingerprint.addr, - false, - priority, - connection_kind, - Some(&session_owner), - ) - .await - else { - debug!("HLS account overlap denied: reason=speculative-acquire-failed"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let Some(provider_config) = provider_handle.allocation.get_provider_config() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - debug!("HLS account overlap denied: reason=missing-provider-config"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) - else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - debug!("HLS account overlap denied: reason=invalid-origin-url"); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let binding = HlsOriginAccountBinding::speculative_from( - Arc::clone(&input.name), - Arc::clone(&candidate.account_name), - proxy_session_id, - candidate.proxy_session_id.clone(), - candidate.reclaim_until_ms, - now_ms, - ); - { - let mut session_guard = new_session.write().await; - session_guard.replace_origin_account_binding(Some(binding.clone())); - } - debug!( - "HLS account overlap granted: account={} victim_proxy_session={} winner_proxy_session={} reclaim_until_ms={} eligible_after_ms={} delay_ms={} tuliprox_target_user_connections={} origin_input_account_connections={}", - sanitize_sensitive_info(candidate.account_name.as_ref()), - safe_proxy_session_id(&candidate.proxy_session_id), - safe_proxy_session_id(proxy_session_id), - candidate.reclaim_until_ms, - candidate.soft_overlap_eligible_at_ms, - candidate.soft_overlap_delay_ms, - candidate.tuliprox_target_user_connection_capacity, - candidate.origin_input_account_connection_capacity - ); - Ok(PreparedHlsOriginRuntime { - url_failover_provider: hls_url_failover_provider_for_origin_context( - input, - raw_request_url, - session_entry_url, - &fetch_url, - ), - fetch_url, - origin_account_binding_to_store: Some(binding), - preacquired_origin_account_handle: Some(provider_handle), - }) -} - -async fn find_hls_account_overlap_candidate( - app_state: &Arc, - input: &ConfigInput, - new_proxy_session_id: &ProxySessionId, - now_ms: u64, -) -> Option { - let sessions = app_state.hls_proxy.sessions().list_sessions().await; - let tuliprox_target_user_connection_capacity = hls_tuliprox_target_user_connection_capacity(app_state, input).await; - let origin_input_account_connection_capacity = hls_origin_input_account_connection_capacity(app_state, input).await; - let mut speculative_accounts = Vec::new(); - for session in &sessions { - let session = session.read().await; - let Some(binding) = session.origin_account_binding.as_ref() else { - continue; - }; - if binding.input_name != input.name { - continue; - } - if matches!( - binding.binding_mode, - HlsOriginAccountBindingMode::Speculative { reclaim_until_ms, .. } if now_ms <= reclaim_until_ms - ) { - speculative_accounts.push(Arc::clone(&binding.account_name)); - } - } - - let mut candidates = Vec::new(); - for session in sessions { - let session_guard = session.read().await; - if session_guard.proxy_session_id == *new_proxy_session_id { - continue; - } - let Some(binding) = session_guard.origin_account_binding.as_ref() else { - continue; - }; - if binding.input_name != input.name - || speculative_accounts.iter().any(|account| account == &binding.account_name) - { - continue; - } - if !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { - continue; - } - if session_guard.activity.active_origin_work_count > 0 { - continue; - } - let timing = session_guard.account_overlap_timing(); - let protection = session_guard.account_binding_protection(now_ms); - debug!( - "HLS account protection classified: proxy_session={} state={} target_duration_ms={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - protection.as_log_state(), - timing.target_duration_ms - ); - let HlsAccountBindingProtection::SoftActive { reclaim_until_ms } = protection else { - continue; - }; - let last_media_at_ms = session_guard.activity.last_authorized_media_at_ms.unwrap_or_default(); - let capacity = hls_soft_overlap_capacity_for_target_duration( - tuliprox_target_user_connection_capacity, - origin_input_account_connection_capacity, - timing.target_duration_ms, - ); - let eligible_at_ms = last_media_at_ms.saturating_add(capacity.delay_ms); - if now_ms < eligible_at_ms { - debug!( - "HLS account overlap waiting: proxy_session={} account={} eligible_at_ms={} now_ms={} delay_ms={} tuliprox_target_user_connections={} origin_input_account_connections={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(binding.account_name.as_ref()), - eligible_at_ms, - now_ms, - capacity.delay_ms, - capacity.tuliprox_target_user_connection_capacity, - capacity.origin_input_account_connection_capacity - ); - continue; - } - candidates.push(HlsAccountOverlapCandidate { - proxy_session_id: session_guard.proxy_session_id.clone(), - input_name: Arc::clone(&binding.input_name), - account_name: Arc::clone(&binding.account_name), - session_owner: binding.session_owner.clone(), - reclaim_until_ms, - last_media_at_ms, - soft_overlap_eligible_at_ms: eligible_at_ms, - soft_overlap_delay_ms: capacity.delay_ms, - tuliprox_target_user_connection_capacity: capacity.tuliprox_target_user_connection_capacity, - origin_input_account_connection_capacity: capacity.origin_input_account_connection_capacity, - }); - } - let mut eligible = filter_hls_account_overlap_cooldowns(app_state, candidates, now_ms).await; - eligible.sort_by_key(|candidate| (candidate.last_media_at_ms, candidate.soft_overlap_eligible_at_ms)); - eligible.into_iter().next() -} - -async fn filter_hls_account_overlap_cooldowns( - app_state: &Arc, - candidates: Vec, - now_ms: u64, -) -> Vec { - let mut eligible = Vec::new(); - for candidate in candidates { - if app_state - .hls_proxy - .is_account_overlap_cooling_down(&candidate.input_name, &candidate.account_name, now_ms) - .await - { - debug!( - "HLS account overlap skipped: proxy_session={} account={} reason=cooldown-active", - safe_proxy_session_id(&candidate.proxy_session_id), - sanitize_sensitive_info(candidate.account_name.as_ref()) - ); - continue; - } - eligible.push(candidate); - } - eligible -} - -async fn reclaim_hls_account_overlap_if_needed( - app_state: &Arc, - winner_session: &HlsSessionHandle, - now_ms: u64, -) { - let winner_proxy_session_id = winner_session.read().await.proxy_session_id.clone(); - let sessions = app_state.hls_proxy.sessions().list_sessions().await; - for session in sessions { - let (loser_proxy_session_id, loser_binding) = { - let session_guard = session.read().await; - let Some(binding) = session_guard.origin_account_binding.clone() else { - continue; - }; - let HlsOriginAccountBindingMode::Speculative { displaced_proxy_session_id, reclaim_until_ms } = - &binding.binding_mode - else { - continue; - }; - if displaced_proxy_session_id != &winner_proxy_session_id || now_ms > *reclaim_until_ms { - continue; - } - (session_guard.proxy_session_id.clone(), binding) - }; - app_state.active_provider.clear_provider_reservation(&loser_binding.session_owner).await; - { - let mut loser = session.write().await; - if let Some(binding) = loser.origin_account_binding.as_mut() { - binding.detach(HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, now_ms); - } - loser.invalidate_queued_origin_work(); - } - { - let mut winner = winner_session.write().await; - if let Some(binding) = winner.origin_account_binding.as_mut() { - binding.promote_to_active(); - } - } - let hard_active_window_ms = winner_session.read().await.account_overlap_timing().hard_active_window_ms; - app_state - .hls_proxy - .mark_account_overlap_reclaimed_cooldown( - Arc::clone(&loser_binding.input_name), - Arc::clone(&loser_binding.account_name), - now_ms, - hard_active_window_ms, - ) - .await; - debug!( - "HLS account overlap reclaimed: account={} winner={} loser={}", - sanitize_sensitive_info(loser_binding.account_name.as_ref()), - safe_proxy_session_id(&winner_proxy_session_id), - safe_proxy_session_id(&loser_proxy_session_id) - ); - debug!( - "HLS origin binding detached: proxy_session={} account={} reason={}", - safe_proxy_session_id(&loser_proxy_session_id), - sanitize_sensitive_info(loser_binding.account_name.as_ref()), - HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner.as_log_reason() - ); - } -} - -async fn promote_elapsed_hls_account_overlaps(app_state: &Arc, now_ms: u64) { - let sessions = app_state.hls_proxy.sessions().list_sessions().await; - for session in sessions { - let (input_name, account_name, promoted_session_id, displaced_session_id, hard_active_window_ms) = { - let mut session_guard = session.write().await; - let hard_active_window_ms = session_guard.account_overlap_timing().hard_active_window_ms; - let Some(binding) = session_guard.origin_account_binding.as_mut() else { - continue; - }; - let HlsOriginAccountBindingMode::Speculative { displaced_proxy_session_id, reclaim_until_ms } = - &binding.binding_mode - else { - continue; - }; - if now_ms <= *reclaim_until_ms { - continue; - } - let displaced_session_id = displaced_proxy_session_id.clone(); - let input_name = Arc::clone(&binding.input_name); - let account_name = Arc::clone(&binding.account_name); - binding.promote_to_active(); - ( - input_name, - account_name, - session_guard.proxy_session_id.clone(), - displaced_session_id, - hard_active_window_ms, - ) - }; - app_state - .hls_proxy - .mark_account_overlap_promoted_cooldown( - Arc::clone(&input_name), - Arc::clone(&account_name), - now_ms, - hard_active_window_ms, - ) - .await; - if let Some(displaced) = app_state.hls_proxy.sessions().get_by_proxy_session_id(&displaced_session_id).await { - let mut detached = false; - let mut displaced = displaced.write().await; - if displaced.origin_account_binding.as_ref().is_some_and(|binding| binding.account_name == account_name) { - if let Some(binding) = displaced.origin_account_binding.as_mut() { - binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, now_ms); - detached = true; - } - displaced.invalidate_queued_origin_work(); - } - if detached { - debug!( - "HLS origin binding detached: proxy_session={} account={} reason={}", - safe_proxy_session_id(&displaced_session_id), - sanitize_sensitive_info(account_name.as_ref()), - HlsOriginAccountDetachedReason::SoftWindowElapsed.as_log_reason() - ); - } - } - debug!( - "HLS account overlap promoted: account={} proxy_session={}", - sanitize_sensitive_info(account_name.as_ref()), - safe_proxy_session_id(&promoted_session_id) - ); - } -} - -#[allow(clippy::too_many_arguments)] -async fn rebind_hls_origin_account( - app_state: &Arc, - session: &HlsSessionHandle, - input: &ConfigInput, - raw_request_url: &str, - session_entry_url: &str, - stale_binding: &HlsOriginAccountBinding, - stale_status: HlsOriginAccountStatus, - fingerprint: &Fingerprint, - connection_kind: crate::api::model::ConnectionKind, - priority: i8, - now_ms: u64, -) -> Result { - { - let mut session_guard = session.write().await; - if !session_guard.origin_account_rebind.is_allowed_now(now_ms) { - debug!( - "HLS origin account rebind skipped by backoff: proxy_session={} old_account={} retry_after_ms=2000", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(stale_binding.account_name.as_ref()) - ); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - } - session_guard.origin_account_rebind.mark_attempt_started(Arc::clone(&stale_binding.account_name), now_ms); - } - - let safe_proxy_session = { - let session_guard = session.read().await; - safe_proxy_session_id(&session_guard.proxy_session_id) - }; - debug!( - "HLS origin account rebind started: proxy_session={} old_account={} reason={stale_status:?}", - safe_proxy_session, - sanitize_sensitive_info(stale_binding.account_name.as_ref()) - ); - app_state.active_provider.clear_provider_reservation(&stale_binding.session_owner).await; - { - let mut session_guard = session.write().await; - if let Some(binding) = session_guard.origin_account_binding.as_mut().filter(|binding| { - binding.account_name == stale_binding.account_name && binding.session_owner == stale_binding.session_owner - }) { - binding.detach(HlsOriginAccountDetachedReason::AccountMissingOrExpired, now_ms); - debug!( - "HLS origin binding detached: proxy_session={} account={} reason={}", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(stale_binding.account_name.as_ref()), - HlsOriginAccountDetachedReason::AccountMissingOrExpired.as_log_reason() - ); - } - session_guard.invalidate_queued_origin_work(); - } - - let Some(provider_handle) = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &fingerprint.addr, - false, - priority, - connection_kind, - Some(&stale_binding.session_owner), - ) - .await - else { - mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "no_account_available").await; - return Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { - reason: hls_no_account_reason_for_binding(Some(stale_binding)), - }); - }; - - let Some(provider_config) = provider_handle.allocation.get_provider_config() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "no_provider_config").await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) - else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "invalid_origin_url").await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let Some(new_account_name) = provider_handle.allocation.get_provider_name() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "missing_account_name").await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - - let new_binding = HlsOriginAccountBinding::rebound( - Arc::clone(&input.name), - new_account_name, - stale_binding.session_owner.clone(), - stale_binding.generation.saturating_add(1), - now_ms, - ); - { - let mut session_guard = session.write().await; - session_guard.replace_origin_account_binding(Some(new_binding.clone())); - session_guard.origin_account_rebind.mark_success(); - } - debug!( - "HLS origin account rebound: old_account={} new_account={}", - sanitize_sensitive_info(stale_binding.account_name.as_ref()), - sanitize_sensitive_info(new_binding.account_name.as_ref()) - ); - - Ok(PreparedHlsOriginRuntime { - url_failover_provider: hls_url_failover_provider_for_origin_context( - input, - raw_request_url, - session_entry_url, - &fetch_url, - ), - fetch_url, - origin_account_binding_to_store: None, - preacquired_origin_account_handle: Some(provider_handle), - }) -} - -async fn mark_hls_origin_rebind_failed( - session: &HlsSessionHandle, - stale_binding: &HlsOriginAccountBinding, - now_ms: u64, - reason: &str, -) { - let mut session_guard = session.write().await; - session_guard.origin_account_rebind.mark_failed(now_ms); - debug!( - "HLS origin account rebind failed: proxy_session={} old_account={} reason={reason} retry_after_ms=2000", - safe_proxy_session_id(&session_guard.proxy_session_id), - sanitize_sensitive_info(stale_binding.account_name.as_ref()) - ); -} - -#[allow(clippy::too_many_arguments)] -async fn prepare_hls_cache_user_session( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - session_token: &str, - virtual_id: u32, - request_url: &str, - input: &ConfigInput, - connection_permission: UserConnectionPermission, - connection_kind: Option, -) -> String { - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token, - virtual_id, - provider: input.name.as_ref(), - stream_url: request_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind, - socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), - }) - .await -} - -fn hls_entry_origin_connection_kind( - connection_permission: UserConnectionPermission, - connection_kind: Option, -) -> Option { - match connection_permission { - UserConnectionPermission::Allowed | UserConnectionPermission::GracePeriod => connection_kind, - UserConnectionPermission::Exhausted => None, - } -} - -#[allow(clippy::too_many_arguments)] -async fn create_hls_cache_entry_master_playlist_response( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - origin_source: HlsOriginSource, - virtual_id: u32, - existing_user_session: Option<&UserSession>, - known_bitrate_bps: Option, - session_token_hint: Option<&str>, - request_url: &str, - input: &ConfigInput, - connection_permission: UserConnectionPermission, - connection_kind: Option, - server_path: Option<&str>, -) -> axum::response::Response { - let item_bandwidth = HlsMasterBandwidth::new(known_bitrate_bps); - let database_bitrate_bps = if item_bandwidth.is_unknown() { - match load_input_live_bitrate_bps(&app_state.app_config, input, &origin_source.stream_ref).await { - Ok(known_bitrate_bps) => known_bitrate_bps, - Err(err) => { - warn!("HLS entry live bitrate lookup failed; using fallback: input_id={} error={err}", input.id); - None - } - } - } else { - None - }; - let bandwidth = HlsMasterBandwidthSelection::resolve(known_bitrate_bps, database_bitrate_bps); - let known_bitrate_bps = bandwidth.known_bitrate_bps(); - - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let family_key = HlsPlaybackFamilyKey::new(user.username.clone(), fingerprint.key.clone()); - let now_ms = current_time_millis(); - let origin_connection_kind = hls_entry_origin_connection_kind(connection_permission, connection_kind); - let access_lease_id = new_hls_access_lease_id(); - let existing_token = existing_user_session.map(|session| session.token.as_str()).or(session_token_hint); - let session_token = create_hls_cache_user_session_token( - fingerprint, - &user.username, - virtual_id, - existing_token, - origin_source.archive_reference, - ); - let session_token = prepare_hls_cache_user_session( - app_state, - fingerprint, - user, - &session_token, - virtual_id, - request_url, - input, - connection_permission, - origin_connection_kind, - ) - .await; - let mut lease = HlsAccessLease::pending( - access_lease_id.clone(), - family_key, - proxy_session_id.clone(), - user.username.clone(), - session_token.clone(), - origin_source.input_id, - origin_source.stream_ref.clone(), - virtual_id, - now_ms, - hls_pending_bootstrap_window_ms(app_state), - ) - .with_known_bitrate_bps(known_bitrate_bps) - .with_archive_playback( - origin_source.archive_reference, - origin_source.archive_reference.map(|_| request_url.to_string()), - ); - if let Some(connection_kind) = origin_connection_kind { - lease = lease.with_origin_acquire_policy(connection_kind, connection_priority_for_kind(user, connection_kind)); - } else { - lease.state = HlsAccessLeaseState::Denied; - } - app_state.hls_proxy.prepare_access_lease(lease).await; - debug!( - "HLS access lease prepared: lease={} session={} proxy_session={} user_session={} action=created reason=new-playback", - safe_hls_access_lease_id(&access_lease_id), - safe_session_key(&session_key), - safe_proxy_session_id(&proxy_session_id), - safe_user_session_token(&session_token) - ); - let response = - hls_entry_master_playlist_response(&proxy_session_id, &access_lease_id, bandwidth.bandwidth(), server_path); - app_state.hls_proxy.startup_observability().record_entry_master_response( - access_lease_id.clone(), - HlsLogIdentity::new(&session_key, &proxy_session_id), - current_time_millis(), - ); - debug!( - "HLS master playlist response: {}", - HlsMasterPlaylistResponseDiagnostic { - lease: safe_hls_access_lease_id(&access_lease_id), - session: safe_session_key(&session_key), - proxy_session: safe_proxy_session_id(&proxy_session_id), - user_session: safe_user_session_token(&session_token), - virtual_id, - bandwidth_bps: bandwidth.bandwidth().advertised_bps(), - bandwidth_source: bandwidth.source().as_log_value(), - content_length: response.content_length, - } - ); - response.response -} - -struct HlsEntryMasterPlaylistResponse { - response: axum::response::Response, - content_length: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct HlsMasterPlaylistResponseDiagnostic { - lease: String, - session: String, - proxy_session: String, - user_session: String, - virtual_id: u32, - bandwidth_bps: u32, - bandwidth_source: &'static str, - content_length: usize, -} - -impl std::fmt::Display for HlsMasterPlaylistResponseDiagnostic { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "lease={} session={} proxy_session={} user_session={} virtual_id={} bandwidth_bps={} bandwidth_source={} status=200 content_length={}", - self.lease, - self.session, - self.proxy_session, - self.user_session, - self.virtual_id, - self.bandwidth_bps, - self.bandwidth_source, - self.content_length - ) - } -} - -fn hls_entry_master_playlist_response( - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - bandwidth: HlsMasterBandwidth, - server_path: Option<&str>, -) -> HlsEntryMasterPlaylistResponse { - let path_prefix = normalize_hls_proxy_public_path_prefix(server_path).unwrap_or_default(); - let media_playlist_uri = format!("{path_prefix}{}", hls_canonical_manifest_path(proxy_session_id, access_lease_id)); - let body = HlsSingleVariantMasterPlaylist::new(bandwidth, media_playlist_uri).render().into_bytes(); - let content_length = body.len(); - let response = try_unwrap_body!(axum::response::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") - .header(header::CACHE_CONTROL, "private, no-store, no-cache, must-revalidate") - .header(header::CONTENT_LENGTH, content_length) - .body(Body::from(body))); - HlsEntryMasterPlaylistResponse { response, content_length } -} - -fn hls_canonical_manifest_path(proxy_session_id: &ProxySessionId, access_lease_id: &HlsAccessLeaseId) -> String { - format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, access_lease_id.0) -} - -fn hls_canonical_retry_after_response() -> axum::response::Response { - try_unwrap_body!(axum::response::Response::builder() - .status(axum::http::StatusCode::SERVICE_UNAVAILABLE) - .header(axum::http::header::RETRY_AFTER, cold_start_retry_after_seconds().to_string()) - .body(axum::body::Body::empty())) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HlsCanonicalOwnerRegistration { - Join(HlsCanonicalOwnerRegistrationKind), - FailClosed(HlsCanonicalOwnerRegistrationFailure), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HlsCanonicalOwnerRegistrationKind { - Scheduled, - AlreadyOwned, -} - -impl HlsCanonicalOwnerRegistrationKind { - const fn as_label(self) -> &'static str { - match self { - Self::Scheduled => "scheduled", - Self::AlreadyOwned => "already_owned", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HlsCanonicalOwnerRegistrationFailure { - CapacityExceeded, - RuntimeUnavailable, -} - -impl HlsCanonicalOwnerRegistrationFailure { - const fn as_label(self) -> &'static str { - match self { - Self::CapacityExceeded => "capacity_exceeded", - Self::RuntimeUnavailable => "runtime_unavailable", - } - } -} - -const fn hls_canonical_owner_registration( - registration: HlsAvailabilityReevaluationRegistration, -) -> HlsCanonicalOwnerRegistration { - match registration { - HlsAvailabilityReevaluationRegistration::Scheduled => { - HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::Scheduled) - } - HlsAvailabilityReevaluationRegistration::AlreadyOwned | HlsAvailabilityReevaluationRegistration::Superseded => { - HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) - } - HlsAvailabilityReevaluationRegistration::CapacityExceeded => { - HlsCanonicalOwnerRegistration::FailClosed(HlsCanonicalOwnerRegistrationFailure::CapacityExceeded) - } - HlsAvailabilityReevaluationRegistration::RuntimeUnavailable => { - HlsCanonicalOwnerRegistration::FailClosed(HlsCanonicalOwnerRegistrationFailure::RuntimeUnavailable) - } - } -} - -fn hls_availability_reevaluation_registration_failure_response( - failure: HlsCanonicalOwnerRegistrationFailure, -) -> axum::response::Response { - warn!("HLS availability reevaluation not registered: reason={}", failure.as_label()); - hls_canonical_retry_after_response() -} - -enum HlsCanonicalOwnerResolution { - Live(axum::response::Response), - Terminal(axum::response::Response), - Standalone(axum::response::Response), - FailedClosed { reason: HlsCanonicalOwnerFailureReason, response: axum::response::Response }, -} - -impl HlsCanonicalOwnerResolution { - const fn outcome_label(&self) -> &'static str { - match self { - Self::Live(_) => "live", - Self::Terminal(_) => "terminal", - Self::Standalone(_) => "standalone", - Self::FailedClosed { reason, .. } => reason.as_label(), - } - } - - fn into_response(self) -> axum::response::Response { - match self { - Self::Live(response) - | Self::Terminal(response) - | Self::Standalone(response) - | Self::FailedClosed { response, .. } => response, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HlsCanonicalOwnerFailureReason { - Superseded, - DeadlineElapsed, - LeaseUnavailable, -} - -impl HlsCanonicalOwnerFailureReason { - const fn as_label(self) -> &'static str { - match self { - Self::Superseded => "superseded", - Self::DeadlineElapsed => "deadline_elapsed", - Self::LeaseUnavailable => "lease_unavailable", - } - } - - fn response(self) -> axum::response::Response { - let reason = match self { - Self::DeadlineElapsed => HlsTerminalFailedClosedReason::SafeCommitDeadlineElapsed, - Self::Superseded | Self::LeaseUnavailable => HlsTerminalFailedClosedReason::LeaseStateUnavailable, - }; - hls_terminal_failed_closed_response(reason) - } -} - -struct HlsCanonicalOwnerPending { - deadline_ms: u64, - current_session_available: bool, -} - -enum HlsCanonicalOwnerEvaluation { - Resolved(HlsCanonicalOwnerResolution), - Pending(HlsCanonicalOwnerPending), -} - -fn hls_canonical_owner_failed(reason: HlsCanonicalOwnerFailureReason) -> HlsCanonicalOwnerEvaluation { - HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::FailedClosed { - reason, - response: reason.response(), - }) -} - -struct HlsCanonicalOwnerHandoffContext<'a> { - app_state: &'a Arc, - proxy_session_id: &'a ProxySessionId, - access_lease_id: &'a HlsAccessLeaseId, - expected_lease_issued_at_ms: Option, - strip: &'a crate::model::StripConfig, - server_path: Option<&'a str>, - manifest_commit_requirement: HlsManifestCommitRequirement, - manifest_boundary_rendered_at_ms: u64, - bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, - request_deadline_ms: u64, - safe_session: String, -} - -fn hls_canonical_owner_lease_deadline_ms(lease: &HlsAccessLease) -> u64 { - if lease.state == HlsAccessLeaseState::Pending { - lease.pending_deadline_ms().unwrap_or(lease.valid_until_ms) - } else { - lease.valid_until_ms - } -} - -fn hls_canonical_owner_request_deadline_ms(lease: &HlsAccessLease, wait_timeout: Duration, now_ms: u64) -> u64 { - let lease_deadline_ms = hls_canonical_owner_lease_deadline_ms(lease); - if wait_timeout.is_zero() && lease.state == HlsAccessLeaseState::Pending { - lease_deadline_ms - } else { - lease_deadline_ms.min(now_ms.saturating_add(duration_to_millis_saturating(wait_timeout))) - } -} - -async fn evaluate_hls_canonical_owner_handoff( - context: &HlsCanonicalOwnerHandoffContext<'_>, -) -> HlsCanonicalOwnerEvaluation { - let now_ms = current_time_millis(); - let Some(lease) = context - .app_state - .hls_proxy - .access_lease_response_snapshot(context.access_lease_id, context.proxy_session_id, now_ms) - .await - else { - return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); - }; - if context.expected_lease_issued_at_ms != Some(lease.issued_at_ms) { - return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); - } - match &lease.playback_mode { - HlsLeasePlaybackMode::TerminalTail(_) => { - let Some(response) = - hls_terminal_playback_response(&lease, context.proxy_session_id, context.access_lease_id) - else { - return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); - }; - return HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::Terminal(response)); - } - HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => { - return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); - } - HlsLeasePlaybackMode::Live => {} - } - if matches!( - lease.state, - HlsAccessLeaseState::PolicyRevoking | HlsAccessLeaseState::Expired | HlsAccessLeaseState::Denied - ) { - return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); - } - - let current_session = - context.app_state.hls_proxy.sessions().get_by_proxy_session_id(context.proxy_session_id).await; - if let Some(current_session) = current_session.as_ref() { - let options = hls_cached_manifest_options_for_requirement( - Duration::ZERO, - context.manifest_commit_requirement, - context.manifest_boundary_rendered_at_ms, - ); - if let Some(response) = try_hls_cached_manifest_response( - context.app_state, - current_session, - context.access_lease_id, - lease.state, - context.strip, - context.server_path, - options, - context.bandwidth_learning, - ) - .await - .filter(|response| response.status() == StatusCode::OK) - { - return HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::Live(response)); - } - } - HlsCanonicalOwnerEvaluation::Pending(HlsCanonicalOwnerPending { - deadline_ms: hls_canonical_owner_lease_deadline_ms(&lease).min(context.request_deadline_ms), - current_session_available: current_session.is_some(), - }) -} - -async fn finalize_hls_canonical_owner_handoff( - context: &HlsCanonicalOwnerHandoffContext<'_>, - pending: HlsCanonicalOwnerPending, - deadline_elapsed: bool, -) -> HlsCanonicalOwnerResolution { - match evaluate_hls_canonical_owner_handoff(context).await { - HlsCanonicalOwnerEvaluation::Resolved(resolution) => resolution, - HlsCanonicalOwnerEvaluation::Pending(current) => { - let response = hls_unpublished_lease_channel_unavailable_response( - context.app_state, - context.proxy_session_id, - context.access_lease_id, - ) - .await; - if response.status() == StatusCode::OK { - return HlsCanonicalOwnerResolution::Standalone(response); - } - let reason = if deadline_elapsed { - HlsCanonicalOwnerFailureReason::DeadlineElapsed - } else if !current.current_session_available && !pending.current_session_available { - HlsCanonicalOwnerFailureReason::Superseded - } else { - HlsCanonicalOwnerFailureReason::LeaseUnavailable - }; - HlsCanonicalOwnerResolution::FailedClosed { reason, response: reason.response() } - } - } -} - -async fn join_hls_canonical_manifest_owner( - context: HlsCanonicalOwnerHandoffContext<'_>, - registration: HlsCanonicalOwnerRegistrationKind, -) -> axum::response::Response { - let started_at = tokio::time::Instant::now(); - let coordinator = context.app_state.hls_proxy.availability_reevaluations(); - let resolution = loop { - let mut observer = coordinator.observe_owner(context.proxy_session_id); - let pending = match evaluate_hls_canonical_owner_handoff(&context).await { - HlsCanonicalOwnerEvaluation::Resolved(resolution) => break resolution, - HlsCanonicalOwnerEvaluation::Pending(pending) => pending, - }; - let now_ms = current_time_millis(); - if now_ms >= pending.deadline_ms { - break finalize_hls_canonical_owner_handoff(&context, pending, true).await; - } - let Some(observer) = observer.as_mut() else { - break finalize_hls_canonical_owner_handoff(&context, pending, false).await; - }; - let remaining_ms = pending.deadline_ms.saturating_sub(now_ms); - match tokio::time::timeout(Duration::from_millis(remaining_ms), observer.changed()).await { - Ok( - HlsAvailabilityReevaluationObservation::EvidenceChanged - | HlsAvailabilityReevaluationObservation::OwnerFinished, - ) => {} - Err(_) => break finalize_hls_canonical_owner_handoff(&context, pending, true).await, - } - }; - debug!( - "HLS canonical manifest owner handoff completed: session={} proxy_session={} lease={} registration={} outcome={} wait_ms={}", - context.safe_session, - safe_proxy_session_id(context.proxy_session_id), - safe_hls_access_lease_id(context.access_lease_id), - registration.as_label(), - resolution.outcome_label(), - duration_to_millis_saturating(started_at.elapsed()) - ); - resolution.into_response() -} - -async fn hls_direct_refresh_follow_up( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - refresh_request: OriginRefreshRequest, - outcome: HlsOriginRefreshTriggerOutcome, -) -> Option { - match outcome { - HlsOriginRefreshTriggerOutcome::Started - | HlsOriginRefreshTriggerOutcome::SessionUnavailable - | HlsOriginRefreshTriggerOutcome::InFlight - | HlsOriginRefreshTriggerOutcome::DebouncedUntil { .. } => return None, - HlsOriginRefreshTriggerOutcome::RecoveryPressureSuperseded => { - warn!("HLS direct origin refresh evidence superseded; scheduling current availability reevaluation"); - } - HlsOriginRefreshTriggerOutcome::RecoveryPressureStateContention => { - warn!("HLS direct origin refresh state contended; scheduling current availability reevaluation"); - } - } - let Some(owner_key) = app_state.hls_proxy.availability_reevaluation_owner_key(session, proxy_session_id).await - else { - warn!("HLS direct origin refresh follow-up unavailable: reason=session_superseded"); - return Some(hls_canonical_retry_after_response()); - }; - match register_hls_availability_reevaluation(app_state.hls_ctx(), Arc::clone(session), owner_key, refresh_request) { - HlsAvailabilityReevaluationRegistration::Scheduled - | HlsAvailabilityReevaluationRegistration::AlreadyOwned - | HlsAvailabilityReevaluationRegistration::Superseded => None, - HlsAvailabilityReevaluationRegistration::CapacityExceeded => { - warn!("HLS direct origin refresh follow-up unavailable: reason=capacity_exceeded"); - Some(hls_canonical_retry_after_response()) - } - HlsAvailabilityReevaluationRegistration::RuntimeUnavailable => { - warn!("HLS direct origin refresh follow-up unavailable: reason=runtime_unavailable"); - Some(hls_canonical_retry_after_response()) - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HlsManifestRefreshOrdering { - Background, - AwaitBeforeTerminalEvaluation, -} - -async fn trigger_hls_canonical_manifest_refresh( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - refresh_request: OriginRefreshRequest, - ordering: HlsManifestRefreshOrdering, -) -> Option { - match ordering { - HlsManifestRefreshOrdering::Background => { - let outcome = maybe_trigger_origin_refresh_with_outcome(refresh_request.clone()).await; - hls_direct_refresh_follow_up(app_state, session, proxy_session_id, refresh_request, outcome).await - } - HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation => { - // An already-owned refresh cannot be joined through this call, but - // it must not bypass the lease-specific terminal decision. The - // in-flight owner will still publish its eventual progress/failure. - let _refresh_started = trigger_origin_refresh_sync(refresh_request).await; - let now_ms = current_time_millis(); - let Some(lease) = - app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await - else { - return Some(hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::LeaseStateUnavailable)); - }; - resolve_hls_terminal_manifest_state(app_state, session, proxy_session_id, access_lease_id, lease, now_ms) - .await - .err() - .map(|response| *response) - } - } -} - -fn hls_canonical_status_response(status: StatusCode) -> axum::response::Response { - if status == StatusCode::SERVICE_UNAVAILABLE { - hls_canonical_retry_after_response() - } else { - status.into_response() - } -} - -struct HlsEntryOriginAccountReservation { - request_url: String, - session_token: String, - provider_handle: Option, - selected_provider_config: Option>, -} - -#[allow(clippy::too_many_arguments)] -async fn try_reserve_hls_entry_origin_account_for_redirect( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - input: &ConfigInput, - virtual_id: u32, - request_url: &str, - user_session_token: &str, - session_owner: &str, - reservation_ttl_secs: u64, - connection_permission: UserConnectionPermission, - connection_kind: crate::api::model::ConnectionKind, - create_user_session: bool, -) -> Option { - let provider_handle = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &fingerprint.addr, - false, - connection_priority_for_kind(user, connection_kind), - connection_kind, - Some(session_owner), - ) - .await?; - - let Some(provider_config) = provider_handle.allocation.get_provider_config() else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return None; - }; - let Some(stream_url) = get_stream_alternative_url(request_url, input, &provider_config) else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return None; - }; - - let session_token = if create_user_session { - app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token: user_session_token, - virtual_id, - provider: &provider_config.name, - stream_url: &stream_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: Some(connection_kind), - socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), - }) - .await - } else { - user_session_token.to_string() - }; - - app_state - .active_provider - .refresh_provider_reservation(&provider_config.name, session_owner, reservation_ttl_secs) - .await; - - Some(HlsEntryOriginAccountReservation { - request_url: stream_url, - session_token, - provider_handle: Some(provider_handle), - selected_provider_config: Some(provider_config), - }) -} - -#[allow(clippy::too_many_arguments)] -async fn try_reserve_hls_virtual_entry_origin_account_for_redirect( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - target: &Arc, - input: &ConfigInput, - stream_identity: &HlsEntryStreamIdentity, -) -> bool { - let virtual_id = stream_identity.virtual_id(); - let session_token = - create_playback_session_fingerprint(fingerprint, &user.username, virtual_id, PlaylistItemType::LiveHls, None); - let (connection_admission, _, _) = resolve_playback_request_admission( - &app_state.admission_ctx(), - user, - fingerprint, - None, - &session_token, - false, - EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, - false, - false, - ) - .await; - if connection_admission.permission == UserConnectionPermission::Exhausted { - return false; - } - - let Some(channel) = get_stream_channel(app_state, target, virtual_id).await else { - return false; - }; - let Ok(origin_playlist_url) = - resolve_hls_origin_playlist_url(app_state, target, input, virtual_id, channel.url.as_ref()).await - else { - return false; - }; - let Some(hls_cache_origin) = build_hls_origin_resolution(input, &origin_playlist_url) else { - return false; - }; - let Some(connection_kind) = connection_admission.kind else { - return false; - }; - let (shared_hls_session_owner, reservation_ttl_secs) = if hls_cache_enabled_for_target(app_state, target) { - let origin_source = build_hls_origin_source(input, stream_identity.stream_ref()); - let proxy_session_id = build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); - let reservation_ttl_secs = match app_state.hls_proxy.sessions().get_by_key(&origin_source.session_key()).await { - Some(session) => hls_origin_account_reservation_ttl_secs_for_session(&session).await, - None => hls_origin_account_reservation_ttl_secs_fallback(), - }; - (Some(build_hls_origin_session_owner(&proxy_session_id)), reservation_ttl_secs) - } else { - (None, get_hls_session_ttl_secs(app_state)) - }; - let session_owner = shared_hls_session_owner.as_deref().unwrap_or(session_token.as_str()); - - let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( - app_state, - fingerprint, - user, - input, - virtual_id, - hls_cache_origin.session_entry_url.as_str(), - &session_token, - session_owner, - reservation_ttl_secs, - connection_admission.permission, - connection_kind, - false, - ) - .await - else { - return false; - }; - - app_state.connection_manager.release_provider_handle(reservation.provider_handle).await; - true -} - -async fn mark_hls_provisioning_handoff_discontinuity( - app_state: &Arc, - input: &ConfigInput, - stream_identity: &HlsEntryStreamIdentity, - access_lease_id: Option<&HlsAccessLeaseId>, - now_ms: u64, -) -> bool { - if !hls_cache_configured(app_state) { - return false; - } - let origin_source = build_hls_origin_source(input, stream_identity.stream_ref()); - let Some(session) = app_state.hls_proxy.sessions().get_by_key(&origin_source.session_key()).await else { - return false; - }; - mark_hls_provisioning_handoff_discontinuity_once_for_session( - app_state, - &session, - input, - stream_identity.virtual_id(), - access_lease_id, - now_ms, - ) - .await -} - -async fn mark_hls_provisioning_handoff_discontinuity_once_for_session( - app_state: &Arc, - session: &HlsSessionHandle, - input: &ConfigInput, - virtual_id: u32, - access_lease_id: Option<&HlsAccessLeaseId>, - now_ms: u64, -) -> bool { - let proxy_session_id = session.read().await.proxy_session_id.clone(); - if !app_state.hls_provisioning.mark_handoff_once( - &input.name, - virtual_id, - Some(&proxy_session_id), - access_lease_id, - now_ms, - ) { - debug!( - "HLS provisioning handoff discontinuity already marked: proxy_session={}", - safe_proxy_session_id(&proxy_session_id) - ); - return false; - } - mark_hls_provisioning_handoff_discontinuity_for_session(session, now_ms).await; - ensure_shared_hls_provisioning_handoff_gap(app_state, session, now_ms).await; - true -} - -async fn mark_hls_provisioning_handoff_discontinuity_for_session(session: &HlsSessionHandle, now_ms: u64) { - let discontinuity_sequence = hls_provisioning_discontinuity_sequence(now_ms); - let proxy_session_id = { - let mut session = session.write().await; - session.mark_pending_handoff_discontinuity(discontinuity_sequence); - session.proxy_session_id.clone() - }; - debug!( - "HLS provisioning handoff discontinuity marked: proxy_session={} discontinuity_sequence={}", - safe_proxy_session_id(&proxy_session_id), - discontinuity_sequence - ); -} - -fn clear_hls_provisioning_handoff_consumer( - app_state: &Arc, - input: &ConfigInput, - virtual_id: u32, - now_ms: u64, -) { - if !app_state.hls_provisioning.take_ready_slot_for_consumer(&input.name, virtual_id, now_ms) { - app_state.hls_provisioning.clear_consumer(&input.name, virtual_id); - } -} - -async fn maybe_mark_hls_provisioning_handoff_for_canonical_manifest( - app_state: &Arc, - session: &HlsSessionHandle, - input: &ConfigInput, - virtual_id: u32, - access_lease_id: &HlsAccessLeaseId, - now_ms: u64, -) -> Option { - if !app_state.hls_provisioning.has_consumer(&input.name, virtual_id, now_ms) { - return None; - } - let previous_manifest_rendered_at_ms = latest_shared_hls_manifest_rendered_at_ms(session).await; - mark_hls_provisioning_handoff_discontinuity_once_for_session( - app_state, - session, - input, - virtual_id, - Some(access_lease_id), - now_ms, - ) - .await - .then_some(previous_manifest_rendered_at_ms) -} - -async fn latest_shared_hls_manifest_rendered_at_ms(session: &HlsSessionHandle) -> u64 { - let session = session.read().await; - session - .last_rendered_manifest - .as_ref() - .map_or(0, |rendered| rendered.rendered_at_ms) - .max(session.transient.last_manifest_rendered_at_ms.unwrap_or(0)) -} - -#[allow(clippy::too_many_arguments)] -pub(in crate::api) async fn hls_panel_provisioning_poll_manifest_response( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - target: &Arc, - input: &ConfigInput, - stream_identity: &HlsEntryStreamIdentity, - original_hls_entry_path: &str, - server_path: Option<&str>, -) -> axum::response::Response { - hls_panel_provisioning_poll_response( - app_state, - fingerprint, - user, - target, - input, - stream_identity, - original_hls_entry_path, - server_path, - HlsProvisioningPollResponseKind::Legacy, - ) - .await -} - -enum HlsProvisioningPollResponseKind { - Legacy, -} - -impl HlsProvisioningPollResponseKind { - fn access_lease_id(&self) -> Option<&HlsAccessLeaseId> { - match self { - Self::Legacy => None, - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn hls_panel_provisioning_poll_response( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &ProxyUserCredentials, - target: &Arc, - input: &ConfigInput, - stream_identity: &HlsEntryStreamIdentity, - ready_redirect_path: &str, - server_path: Option<&str>, - response_kind: HlsProvisioningPollResponseKind, -) -> axum::response::Response { - let virtual_id = stream_identity.virtual_id(); - let now_ms = current_time_millis(); - app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), virtual_id, now_ms); - - let existing_status = app_state.hls_provisioning.consumer_status(&input.name, virtual_id, now_ms); - - if try_reserve_hls_virtual_entry_origin_account_for_redirect( - app_state, - fingerprint, - user, - target, - input, - stream_identity, - ) - .await - { - mark_hls_provisioning_handoff_discontinuity( - app_state, - input, - stream_identity, - response_kind.access_lease_id(), - now_ms, - ) - .await; - clear_hls_provisioning_handoff_consumer(app_state, input, virtual_id, current_time_millis()); - return hls_virtual_entry_redirect_response(ready_redirect_path, server_path); - } - - let provisioning_enabled = can_provision_on_exhausted(app_state.as_ref(), input); - if provisioning_enabled { - start_hls_panel_provisioning_once(app_state, input); - } - - let status = existing_status.unwrap_or(if provisioning_enabled { - HlsProvisioningStatus::InProgress - } else { - HlsProvisioningStatus::ProviderExhausted - }); - - match status { - HlsProvisioningStatus::Ready | HlsProvisioningStatus::InProgress => { - hls_custom_video_manifest_response_with_virtual_id( - app_state, - user, - CustomVideoStreamType::Provisioning, - StatusCode::SERVICE_UNAVAILABLE, - Some(virtual_id), - ) - .await - } - HlsProvisioningStatus::ProviderExhausted => { - hls_custom_video_manifest_response_with_virtual_id( - app_state, - user, - CustomVideoStreamType::ProviderConnectionsExhausted, - StatusCode::SERVICE_UNAVAILABLE, - Some(virtual_id), - ) - .await - } - } -} - -async fn hls_panel_provisioning_or_status_response( - app_state: &Arc, - user: &ProxyUserCredentials, - input: &ConfigInput, - virtual_id: u32, - _original_hls_entry_path: &str, - server_path: Option<&str>, - fallback_status: StatusCode, -) -> axum::response::Response { - try_hls_panel_provisioning_manifest_response( - app_state, - user, - input, - virtual_id, - HlsPanelProvisioningRedirectPaths { waiting_manifest_path: None }, - server_path, - fallback_status, - ) - .await - .unwrap_or_else(|| fallback_status.into_response()) -} - -#[derive(Debug, Clone)] -struct SharedHlsProvisioningSegmentPlan { - proxy_seq: u64, - physical_index: usize, - cache_key: SegmentCacheKey, - segment_kind: SharedHlsProvisioningLocalSegmentKind, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum SharedHlsProvisioningLocalSegmentKind { - Provisioning, - Gap, -} - -fn shared_hls_provisioning_segment_plans( - session: &HlsSession, - physical_segment_count: usize, -) -> Vec { - let existing_provisioning_segments = - session.segments.values().filter(|entry| is_hls_provisioning_segment(entry)).count(); - let append_count = if existing_provisioning_segments == 0 { 3 } else { 1 }; - let start_proxy_seq = session.proxy_next_seq.unwrap_or(0); - (0..append_count) - .filter_map(|offset| { - let proxy_seq = start_proxy_seq.checked_add(u64::try_from(offset).ok()?)?; - if session.segments.contains_key(&proxy_seq) { - return None; - } - Some(SharedHlsProvisioningSegmentPlan { - proxy_seq, - physical_index: (existing_provisioning_segments + offset) % physical_segment_count, - cache_key: SegmentCacheKey::new(session.proxy_session_id.clone(), proxy_seq, "ts"), - segment_kind: SharedHlsProvisioningLocalSegmentKind::Provisioning, - }) - }) - .collect() -} - -fn shared_hls_provisioning_segment_entry( - plan: SharedHlsProvisioningSegmentPlan, - content_length: u64, - duration_ms: u64, - now_ms: u64, -) -> SegmentEntry { - let origin_epoch = match plan.segment_kind { - SharedHlsProvisioningLocalSegmentKind::Provisioning => HLS_PROVISIONING_ORIGIN_EPOCH, - SharedHlsProvisioningLocalSegmentKind::Gap => HLS_PROVISIONING_GAP_ORIGIN_EPOCH, - }; - SegmentEntry { - origin_key: OriginSegmentKey { - origin_epoch, - effective_host_id: 0, - host_local_sequence: plan.proxy_seq, - host_local_index: u32::try_from(plan.proxy_seq).unwrap_or(u32::MAX), - }, - proxy_seq: plan.proxy_seq, - duration_ms, - proxy_file_ext: "ts".to_string(), - content_type: "video/mp2t".to_string(), - cache_key: plan.cache_key, - discontinuity_before: false, - program_date_time: None, - daterange_tags_before: Vec::new(), - origin_byte_range: None, - map_ref: None, - encryption: None, - origin_fetch_ref: None, - status: SegmentCacheStatus::Ready { content_length, ready_at_ms: now_ms }, - last_rendered_at_ms: None, - access: Arc::new(CacheAccessState::new()), - } -} - -async fn commit_shared_hls_provisioning_segments( - app_state: &Arc, - session: &HlsSessionHandle, - plans: &[SharedHlsProvisioningSegmentPlan], - provisioning_segments: &[TransportStreamBuffer], -) -> Option> { - let mut committed = Vec::with_capacity(plans.len()); - for plan in plans { - let video = provisioning_segments.get(plan.physical_index)?; - let duration_ms = video.duration_ms().unwrap_or(HLS_PROVISIONING_SEGMENT_DURATION_MS); - let metadata = match app_state - .hls_proxy - .segment_cache() - .write_bytes_and_commit(&plan.cache_key, video.as_bytes()) - .await - { - Ok(metadata) => metadata, - Err(err) => { - let safe_proxy_session = { - let session_guard = session.read().await; - safe_proxy_session_id(&session_guard.proxy_session_id) - }; - warn!( - "HLS provisioning segment cache commit failed for shared manifest: proxy_session={} seq={} error={err}", - safe_proxy_session, plan.proxy_seq - ); - return None; - } - }; - committed.push((plan.clone(), metadata.size, duration_ms)); - } - Some(committed) -} - -async fn ensure_shared_hls_provisioning_handoff_gap( - app_state: &Arc, - session: &HlsSessionHandle, - now_ms: u64, -) -> bool { - let custom_stream_response = app_state.app_config.custom_stream_response.load(); - let Some(provisioning_segments) = custom_stream_response - .as_ref() - .map(|response| response.panel_api_provisioning_hls_segments.clone()) - .filter(|segments| !segments.is_empty()) - else { - return false; - }; - let plan = { - let session_guard = session.read().await; - if !session_guard.segments.values().any(is_hls_provisioning_segment) - || session_guard.segments.values().any(is_hls_provisioning_gap_segment) - { - return false; - } - let proxy_seq = session_guard.proxy_next_seq.unwrap_or(0); - if session_guard.segments.contains_key(&proxy_seq) { - return false; - } - let existing_provisioning_segments = - session_guard.segments.values().filter(|entry| is_hls_provisioning_segment(entry)).count(); - SharedHlsProvisioningSegmentPlan { - proxy_seq, - physical_index: existing_provisioning_segments % provisioning_segments.len(), - cache_key: SegmentCacheKey::new(session_guard.proxy_session_id.clone(), proxy_seq, "ts"), - segment_kind: SharedHlsProvisioningLocalSegmentKind::Gap, - } - }; - let Some(committed) = commit_shared_hls_provisioning_segments( - app_state, - session, - std::slice::from_ref(&plan), - &provisioning_segments, - ) - .await - else { - return false; - }; - let mut session_guard = session.write().await; - let mut inserted = false; - for (plan, content_length, duration_ms) in committed { - if session_guard.segments.contains_key(&plan.proxy_seq) { - continue; - } - if session_guard.publishable_origin_head_proxy_seq.is_none() { - session_guard.publishable_origin_head_proxy_seq = Some(plan.proxy_seq); - } - session_guard.publishable_origin_tail_proxy_seq = Some(plan.proxy_seq); - session_guard.proxy_next_seq = Some(plan.proxy_seq.saturating_add(1)); - session_guard - .segments - .insert(plan.proxy_seq, shared_hls_provisioning_segment_entry(plan, content_length, duration_ms, now_ms)); - inserted = true; - } - if inserted { - session_guard.target_duration = Some(HLS_PROVISIONING_TARGET_DURATION_SECS); - session_guard.independent_segments = true; - } - inserted -} - -async fn hls_shared_provisioning_timeline_manifest_response( - app_state: &Arc, - session: &HlsSessionHandle, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &crate::model::StripConfig, - server_path: Option<&str>, -) -> Option { - let custom_stream_response = app_state.app_config.custom_stream_response.load(); - let provisioning_segments = custom_stream_response - .as_ref() - .map(|response| response.panel_api_provisioning_hls_segments.clone()) - .filter(|segments| !segments.is_empty())?; - let now_ms = current_time_millis(); - let plans = { - let session_guard = session.read().await; - shared_hls_provisioning_segment_plans(&session_guard, provisioning_segments.len()) - }; - if plans.is_empty() { - let mut session_guard = session.write().await; - session_guard.render_and_store_manifest(now_ms).ok()?; - } else { - let committed = - commit_shared_hls_provisioning_segments(app_state, session, &plans, &provisioning_segments).await?; - let mut session_guard = session.write().await; - for (plan, content_length, duration_ms) in committed { - if session_guard.segments.contains_key(&plan.proxy_seq) { - continue; - } - if session_guard.publishable_origin_head_proxy_seq.is_none() { - session_guard.publishable_origin_head_proxy_seq = Some(plan.proxy_seq); - } - session_guard.publishable_origin_tail_proxy_seq = Some(plan.proxy_seq); - session_guard.proxy_next_seq = Some(plan.proxy_seq.saturating_add(1)); - session_guard.segments.insert( - plan.proxy_seq, - shared_hls_provisioning_segment_entry(plan, content_length, duration_ms, now_ms), - ); - } - session_guard.target_duration = Some(HLS_PROVISIONING_TARGET_DURATION_SECS); - session_guard.independent_segments = true; - session_guard.render_and_store_manifest(now_ms).ok()?; - } - - try_hls_cached_manifest_response( - app_state, - session, - access_lease_id, - access_lease_state, - strip, - server_path, - HlsCachedManifestOptions::initial(Duration::ZERO), - HlsRuntimeBandwidthLearningContext::Disabled, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn hls_shared_provisioning_or_provider_exhausted_response( - app_state: &Arc, - session: &HlsSessionHandle, - username: &str, - input: &ConfigInput, - virtual_id: u32, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &crate::model::StripConfig, - server_path: Option<&str>, -) -> axum::response::Response { - let Some((_user, _target)) = app_state.app_config.get_target_for_username(username) else { - return hls_canonical_retry_after_response(); - }; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let now_ms = current_time_millis(); - let provisioning_enabled = can_provision_on_exhausted(app_state.as_ref(), input); - if provisioning_enabled { - app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), virtual_id, now_ms); - start_hls_panel_provisioning_once(app_state, input); - if let Some(HlsProvisioningStatus::ProviderExhausted) = - app_state.hls_provisioning.consumer_status(&input.name, virtual_id, now_ms) - { - return hls_runtime_or_standalone_custom_tail_response( - app_state, - session, - &proxy_session_id, - access_lease_id, - HlsRuntimeCustomTailReason::ProviderConnectionsExhausted, - StatusCode::SERVICE_UNAVAILABLE, - ) - .await; - } - if let Some(response) = hls_shared_provisioning_timeline_manifest_response( - app_state, - session, - access_lease_id, - access_lease_state, - strip, - server_path, - ) - .await - { - return response; - } - } - - let provider_exhausted_custom_response_available = is_custom_video_stream_enabled(&app_state.app_config) - && app_state - .app_config - .custom_stream_response - .load() - .as_ref() - .and_then(|response| response.provider_connections_exhausted.as_ref()) - .is_some(); - if provider_exhausted_custom_response_available { - return hls_runtime_or_standalone_custom_tail_response( - app_state, - session, - &proxy_session_id, - access_lease_id, - HlsRuntimeCustomTailReason::ProviderConnectionsExhausted, - StatusCode::SERVICE_UNAVAILABLE, - ) - .await; - } - hls_canonical_retry_after_response() -} - -enum HlsProviderExhaustedResolution { - RetryAcquire, - Response(axum::response::Response), -} - -#[allow(clippy::too_many_arguments)] -async fn hls_provider_connections_exhausted_manifest_resolution( - app_state: &Arc, - session: &HlsSessionHandle, - username: &str, - input: &ConfigInput, - virtual_id: u32, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &crate::model::StripConfig, - server_path: Option<&str>, - allow_grace_hold: bool, -) -> HlsProviderExhaustedResolution { - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let grace_options = app_state.get_grace_options(); - if allow_grace_hold && grace_options.hold_stream && grace_options.period_millis > 0 { - debug!( - "HLS provider connections exhausted; holding canonical manifest for grace: proxy_session={} lease={} hold_ms={}", - safe_proxy_session_id(&proxy_session_id), - safe_hls_access_lease_id(access_lease_id), - grace_options.period_millis - ); - let capacity_notify = app_state.connection_manager.capacity_notified(); - let deadline = tokio::time::Instant::now() + Duration::from_millis(grace_options.period_millis); - let wake_reason = tokio::select! { - () = capacity_notify.notified() => "capacity-notified", - () = tokio::time::sleep_until(deadline) => "timeout", - }; - debug!( - "HLS provider connections exhausted grace hold completed: proxy_session={} lease={} reason={wake_reason}", - safe_proxy_session_id(&proxy_session_id), - safe_hls_access_lease_id(access_lease_id) - ); - return HlsProviderExhaustedResolution::RetryAcquire; - } - - HlsProviderExhaustedResolution::Response( - hls_shared_provisioning_or_provider_exhausted_response( - app_state, - session, - username, - input, - virtual_id, - access_lease_id, - access_lease_state, - strip, - server_path, - ) - .await, - ) -} - -#[allow(clippy::too_many_arguments)] -async fn prepare_hls_canonical_manifest_origin_runtime( - app_state: &Arc, - session: &HlsSessionHandle, - context: &HlsAccessContext, - origin: &HlsCacheManifestOrigin<'_>, - path_proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - fingerprint: &Fingerprint, - server_path: Option<&str>, - now_ms: u64, -) -> Result> { - let mut allow_grace_hold = true; - loop { - let origin_policy = hls_effective_origin_acquire_policy(session).await; - match prepare_hls_origin_runtime( - app_state, - session, - origin.input, - origin.raw_request_url, - origin.session_entry_url.as_str(), - path_proxy_session_id, - fingerprint, - origin_policy.connection_kind, - origin_policy.priority, - HlsOriginWorkKind::Manifest, - HlsOriginWorkClass::ManifestInteractive, - now_ms, - ) - .await - { - Ok(prepared) => return Ok(prepared), - Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { - reason: HlsOriginRuntimeNoAccountReason::OriginBindingPreempted, - }) => { - return Err(Box::new( - hls_runtime_or_standalone_custom_tail_response( - app_state, - session, - path_proxy_session_id, - access_lease_id, - HlsRuntimeCustomTailReason::LowPriorityPreempted, - StatusCode::SERVICE_UNAVAILABLE, - ) - .await, - )); - } - Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { - reason: HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted, - }) => { - let strip = app_state.hls_proxy.strip(); - match hls_provider_connections_exhausted_manifest_resolution( - app_state, - session, - &context.username, - origin.input, - context.virtual_id, - access_lease_id, - access_lease_state, - &strip, - server_path, - allow_grace_hold, - ) - .await - { - HlsProviderExhaustedResolution::RetryAcquire => { - allow_grace_hold = false; - } - HlsProviderExhaustedResolution::Response(response) => return Err(Box::new(response)), - } - } - Err(HlsOriginRuntimeAcquireError::Fatal(status)) => { - return Err(Box::new(hls_canonical_status_response(status))) - } - } - } -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn try_hls_cache_canonical_manifest_response( - app_state: &Arc, - fingerprint: &Fingerprint, - context: &HlsAccessContext, - path_proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - origin: HlsCacheManifestOrigin<'_>, - headers: HeaderMap, - server_path: Option<&str>, - _original_hls_entry_path: &str, - refresh_ordering: HlsManifestRefreshOrdering, -) -> Option { - if !hls_cache_configured(app_state) { - return None; - } - if origin.origin_source.input_id != context.input_id || origin.origin_source.stream_ref != context.stream_ref { - return Some(StatusCode::NOT_FOUND.into_response()); - } - - let session_key = origin.origin_source.session_key(); - let expected_proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - if &expected_proxy_session_id != path_proxy_session_id { - return Some(StatusCode::NOT_FOUND.into_response()); - } - let now_ms = current_time_millis(); - let rewrite_secret = app_state.get_encrypt_secret(); - let (session, session_outcome) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key, - origin.origin_source.clone(), - &rewrite_secret, - now_ms, - ) - .await; - if access_lease_state == HlsAccessLeaseState::Activated { - let timing = hls_access_lease_timing_for_session(app_state, &session).await; - match app_state - .hls_proxy - .touch_manifest_access_lease( - access_lease_id, - path_proxy_session_id, - now_ms, - Some(timing), - None, - hls_access_lease_ttl_ms(app_state), - ) - .await - { - HlsAccessLeaseTouch::Touched { .. } => {} - HlsAccessLeaseTouch::Denied => { - return Some( - hls_runtime_or_standalone_custom_tail_response( - app_state, - &session, - path_proxy_session_id, - access_lease_id, - HlsRuntimeCustomTailReason::UserConnectionsExhausted, - StatusCode::FORBIDDEN, - ) - .await, - ); - } - HlsAccessLeaseTouch::Expired | HlsAccessLeaseTouch::UnknownLease | HlsAccessLeaseTouch::SessionMismatch => { - return Some(StatusCode::NOT_FOUND.into_response()); - } - } - } - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - path_proxy_session_id, - now_ms, - ) - .await; - let prepared_origin = match prepare_hls_canonical_manifest_origin_runtime( - app_state, - &session, - context, - &origin, - path_proxy_session_id, - access_lease_id, - access_lease_state, - fingerprint, - server_path, - now_ms, - ) - .await - { - Ok(prepared) => prepared, - Err(response) => return Some(*response), - }; - let url_failover_provider = effective_hls_url_failover_provider_for_fetch_url( - &prepared_origin.fetch_url, - prepared_origin.url_failover_provider.clone(), - origin.session_entry_url.url_failover_provider(), - ); - let origin_entry = - LiveHlsOriginEntry::parse_with_url_failover_provider(&prepared_origin.fetch_url, url_failover_provider)?; - { - let mut session_guard = session.write().await; - if session_guard.is_gc_marked_for_removal() { - return Some(hls_canonical_retry_after_response()); - } - if prepared_origin.origin_account_binding_to_store.is_some() { - session_guard.replace_origin_account_binding(prepared_origin.origin_account_binding_to_store); - } - } - mark_hls_authorized_manifest_access(app_state, &session, now_ms).await; - let selected_account = session.read().await.origin_account_binding.as_ref().map_or_else( - || "".to_string(), - |binding| sanitize_sensitive_info(binding.account_name.as_ref()).to_string(), - ); - debug!( - "HLS origin account selected: proxy_session={} account={}", - safe_proxy_session_id(path_proxy_session_id), - selected_account - ); - let reservation_ttl_secs = hls_origin_account_reservation_ttl_secs_for_session(&session).await; - let previous_manifest_rendered_at_ms = latest_shared_hls_manifest_rendered_at_ms(&session).await; - let handoff_previous_rendered_at_ms = maybe_mark_hls_provisioning_handoff_for_canonical_manifest( - app_state, - &session, - origin.input, - context.virtual_id, - access_lease_id, - now_ms, - ) - .await; - let manifest_commit_requirement = - hls_manifest_commit_requirement(&session, session_outcome, handoff_previous_rendered_at_ms, now_ms).await; - let hls_ctx = app_state.hls_ctx(); - let acceptance_evaluation = - hls_manifest_acceptance_directive_for_session(&hls_ctx, &session, path_proxy_session_id).await; - let (acceptance_directive, availability_reevaluation_owner_key) = match acceptance_evaluation { - HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => (directive, None), - HlsManifestAcceptanceEvaluationOutcome::StateContention { owner_key } => { - (HlsManifestAcceptanceDirective::none(), Some(owner_key)) - } - HlsManifestAcceptanceEvaluationOutcome::SessionSuperseded => { - app_state - .connection_manager - .release_provider_handle(prepared_origin.preacquired_origin_account_handle) - .await; - return Some(hls_canonical_retry_after_response()); - } - }; - let manifest_boundary_rendered_at_ms = handoff_previous_rendered_at_ms.unwrap_or(previous_manifest_rendered_at_ms); - let wait_timeout = - hls_manifest_wait_timeout_for_requirement(app_state, &session, manifest_commit_requirement).await; - let cached_manifest_options = hls_cached_manifest_options_for_requirement( - wait_timeout, - manifest_commit_requirement, - manifest_boundary_rendered_at_ms, - ); - let bandwidth_learning = match context.known_bitrate_bps { - Some(_) => HlsRuntimeBandwidthLearningContext::Disabled, - None => HlsRuntimeBandwidthLearningContext::Eligible(origin.input), - }; - - let origin_policy = hls_effective_origin_acquire_policy(&session).await; - let origin_provider_session_headers = session.read().await.origin_provider_session_headers.clone(); - let mut preacquired_provider_handle = prepared_origin.preacquired_origin_account_handle; - let mut origin_io = HlsOriginIoContext { - ctx: hls_ctx.clone(), - client_addr: fingerprint.addr, - allow_grace: HlsOriginWorkClass::ManifestInteractive.allows_grace(), - priority: origin_policy.priority, - connection_kind: origin_policy.connection_kind, - reservation_ttl_secs, - preacquired_provider_handle: None, - started_generation: None, - }; - if availability_reevaluation_owner_key.is_none() { - if let Some(provider_handle) = preacquired_provider_handle.take() { - origin_io = origin_io.with_preacquired_provider_handle(provider_handle); - } - } - - let refresh_request = OriginRefreshRequest { - app_config: Arc::clone(&app_state.app_config), - session: Arc::clone(&session), - origin_entry, - headers, - origin_provider_session_headers, - client: app_state.http_client.load().as_ref().clone(), - no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), - use_manual_redirects: app_state.should_use_manual_redirects(), - segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), - hls_proxy: Arc::clone(&app_state.hls_proxy), - segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), - segment_worker_pool: Arc::clone(app_state.hls_proxy.segment_worker_pool()), - map_worker_pool: Arc::clone(app_state.hls_proxy.map_worker_pool()), - origin_manifest_timeout_ms: app_state.hls_proxy.origin_manifest_timeout_ms(), - manifest_recovery_burst: app_state.hls_proxy.manifest_recovery_burst(), - strip: app_state.hls_proxy.strip().clone(), - retry_policy: RetryPolicy::default(), - reverse_proxy_rewrite_secret: rewrite_secret.to_vec(), - transient_resource_ttl_ms: app_state.hls_proxy.transient_resource_ttl_ms(), - manifest_commit_requirement, - fresh_manifest_requirement_generation: None, - acceptance_directive, - access_lease_id: Some(access_lease_id.clone()), - disabled_headers: app_state.get_disabled_headers(), - now_ms, - origin_io: Some(origin_io), - post_refresh_runtime: Some(HlsPostRefreshRuntime { ctx: hls_ctx.downgrade() }), - }; - let refresh_ordering = if session_outcome == HlsSessionStoreOutcome::Reused { - refresh_ordering - } else { - HlsManifestRefreshOrdering::Background - }; - if let Some(owner_key) = availability_reevaluation_owner_key { - app_state.connection_manager.release_provider_handle(preacquired_provider_handle).await; - touch_initial_manifest_access_lease_window( - app_state, - access_lease_id, - path_proxy_session_id, - access_lease_state, - wait_timeout, - now_ms, - ) - .await; - let owner_wait_lease = app_state - .hls_proxy - .access_lease_response_snapshot(access_lease_id, path_proxy_session_id, current_time_millis()) - .await; - let expected_lease_issued_at_ms = owner_wait_lease.as_ref().map(|lease| lease.issued_at_ms); - let request_deadline_ms = owner_wait_lease - .as_ref() - .map_or(now_ms, |lease| hls_canonical_owner_request_deadline_ms(lease, wait_timeout, now_ms)); - let safe_session = { - let session = session.read().await; - safe_session_key(&session.key) - }; - let registration = - register_hls_availability_reevaluation(hls_ctx, Arc::clone(&session), owner_key, refresh_request); - return Some(match hls_canonical_owner_registration(registration) { - HlsCanonicalOwnerRegistration::Join(registration) => { - let strip = app_state.hls_proxy.strip(); - join_hls_canonical_manifest_owner( - HlsCanonicalOwnerHandoffContext { - app_state, - proxy_session_id: path_proxy_session_id, - access_lease_id, - expected_lease_issued_at_ms, - strip: &strip, - server_path, - manifest_commit_requirement, - manifest_boundary_rendered_at_ms, - bandwidth_learning, - request_deadline_ms, - safe_session, - }, - registration, - ) - .await - } - HlsCanonicalOwnerRegistration::FailClosed(failure) => { - hls_availability_reevaluation_registration_failure_response(failure) - } - }); - } - if handoff_previous_rendered_at_ms.is_some() { - touch_initial_manifest_access_lease_window( - app_state, - access_lease_id, - path_proxy_session_id, - access_lease_state, - wait_timeout, - now_ms, - ) - .await; - if let Some(response) = trigger_hls_canonical_manifest_refresh( - app_state, - &session, - path_proxy_session_id, - access_lease_id, - refresh_request, - refresh_ordering, - ) - .await - { - return Some(response); - } - let strip = app_state.hls_proxy.strip(); - if let Some(response) = try_hls_cached_manifest_response( - app_state, - &session, - access_lease_id, - access_lease_state, - &strip, - server_path, - cached_manifest_options, - bandwidth_learning, - ) - .await - { - clear_hls_provisioning_handoff_consumer(app_state, origin.input, context.virtual_id, current_time_millis()); - return Some(response); - } - return Some(StatusCode::SERVICE_UNAVAILABLE.into_response()); - } - match session_outcome { - HlsSessionStoreOutcome::Created => { - touch_initial_manifest_access_lease_window( - app_state, - access_lease_id, - path_proxy_session_id, - access_lease_state, - wait_timeout, - now_ms, - ) - .await; - if let Some(response) = trigger_hls_canonical_manifest_refresh( - app_state, - &session, - path_proxy_session_id, - access_lease_id, - refresh_request, - refresh_ordering, - ) - .await - { - return Some(response); - } - let strip = app_state.hls_proxy.strip(); - if let Some(response) = try_hls_cached_manifest_response( - app_state, - &session, - access_lease_id, - access_lease_state, - &strip, - server_path, - cached_manifest_options, - bandwidth_learning, - ) - .await - { - return Some(response); - } - } - HlsSessionStoreOutcome::Reused => { - if let Some(response) = trigger_hls_canonical_manifest_refresh( - app_state, - &session, - path_proxy_session_id, - access_lease_id, - refresh_request, - refresh_ordering, - ) - .await - { - if refresh_ordering == HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation - && response.status() == StatusCode::SERVICE_UNAVAILABLE - { - let strip = app_state.hls_proxy.strip(); - if let Some(live_response) = try_hls_cached_manifest_response( - app_state, - &session, - access_lease_id, - access_lease_state, - &strip, - server_path, - HlsCachedManifestOptions::initial(Duration::ZERO), - bandwidth_learning, - ) - .await - .filter(|candidate| candidate.status() == StatusCode::OK) - { - return Some(live_response); - } - } - return Some(response); - } - touch_initial_manifest_access_lease_window( - app_state, - access_lease_id, - path_proxy_session_id, - access_lease_state, - wait_timeout, - now_ms, - ) - .await; - let strip = app_state.hls_proxy.strip(); - if let Some(response) = try_hls_cached_manifest_response( - app_state, - &session, - access_lease_id, - access_lease_state, - &strip, - server_path, - cached_manifest_options, - bandwidth_learning, - ) - .await - { - return Some(response); - } - } - } - - Some(hls_unpublished_lease_channel_unavailable_response(app_state, path_proxy_session_id, access_lease_id).await) -} - -fn hls_initial_manifest_decision_wait_timeout(app_state: &Arc) -> Duration { - Duration::from_secs(app_state.hls_proxy.initial_manifest_wait_timeout_secs()) -} - -async fn hls_manifest_wait_timeout_for_requirement( - app_state: &Arc, - session: &HlsSessionHandle, - requirement: HlsManifestCommitRequirement, -) -> Duration { - match requirement { - HlsManifestCommitRequirement::FreshCommitRequired { .. } => { - hls_initial_manifest_decision_wait_timeout(app_state) - } - HlsManifestCommitRequirement::CommittedManifestAllowed => { - hls_initial_manifest_wait_timeout(app_state, session).await - } - } -} - -async fn touch_initial_manifest_access_lease_window( - app_state: &Arc, - access_lease_id: &HlsAccessLeaseId, - proxy_session_id: &ProxySessionId, - access_lease_state: HlsAccessLeaseState, - wait_timeout: Duration, - now_ms: u64, -) { - if wait_timeout.is_zero() || access_lease_state != HlsAccessLeaseState::Pending { - return; - } - let wait_timeout_ms = duration_to_millis_saturating(wait_timeout); - let deadline_ms = now_ms.saturating_add(wait_timeout_ms.max(hls_pending_bootstrap_window_ms(app_state))); - let touch = app_state - .hls_proxy - .touch_manifest_access_lease( - access_lease_id, - proxy_session_id, - now_ms, - None, - Some(HlsAccessLeasePendingDeadline::Bootstrap { deadline_ms }), - hls_access_lease_ttl_ms(app_state), - ) - .await; - let failure = match touch { - HlsAccessLeaseTouch::Touched { .. } => return, - HlsAccessLeaseTouch::Expired => "expired", - HlsAccessLeaseTouch::Denied => "denied", - HlsAccessLeaseTouch::UnknownLease => "unknown-lease", - HlsAccessLeaseTouch::SessionMismatch => "session-mismatch", - }; - debug!( - "HLS initial manifest lease window not extended: lease={} proxy_session={} outcome={failure}", - safe_hls_access_lease_id(access_lease_id), - safe_proxy_session_id(proxy_session_id) - ); -} - -async fn hls_initial_manifest_wait_timeout(app_state: &Arc, session: &HlsSessionHandle) -> Duration { - let session = session.read().await; - if matches!( - session.account_binding_protection(current_time_millis()), - HlsAccountBindingProtection::NoMediaYet | HlsAccountBindingProtection::Expired - ) { - hls_initial_manifest_decision_wait_timeout(app_state) - } else { - Duration::ZERO - } -} - -async fn hls_segment_request_requires_origin_work(session: &HlsSessionHandle, segment_file: &HlsSegmentFile) -> bool { - let session = session.read().await; - let Some(entry) = session.segments.get(&segment_file.proxy_seq) else { - return false; - }; - if entry.proxy_file_ext != segment_file.extension { - return false; - } - matches!(entry.status, SegmentCacheStatus::Discovered | SegmentCacheStatus::Queued { .. }) - && entry.origin_fetch_ref.is_some() -} - -async fn hls_origin_binding_needs_reacquire(session: &HlsSessionHandle) -> bool { - let session = session.read().await; - session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_detached) -} - -fn hls_transient_origin_binding_requires_runtime_prepare(hls_ctx: &HlsCtx, binding: &HlsOriginAccountBinding) -> bool { - binding.is_detached() - || (binding.is_active() - && matches!( - hls_origin_account_status(hls_ctx, binding), - HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired - )) -} - -async fn prepare_hls_origin_binding_for_authorized_resource_work( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - req_headers: &HeaderMap, - work_kind: HlsOriginWorkKind, - now_ms: u64, -) -> Result, HlsOriginRuntimeAcquireError> { - if !hls_origin_binding_needs_reacquire(session).await { - return Ok(None); - } - if session.read().await.activity.active_origin_work_count > 0 { - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - } - let request_context = resolve_hls_playback_manifest_request_context(app_state, access_context, req_headers) - .await - .map_err(HlsOriginRuntimeAcquireError::Fatal)?; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let origin_policy = hls_effective_origin_acquire_policy(session).await; - let prepared_origin = prepare_hls_origin_runtime( - app_state, - session, - &request_context.input, - &request_context.hls_url, - request_context.session_entry_url.as_str(), - &proxy_session_id, - fingerprint, - origin_policy.connection_kind, - origin_policy.priority, - work_kind, - HlsOriginWorkClass::Demand, - now_ms, - ) - .await?; - if let Some(binding) = prepared_origin.origin_account_binding_to_store { - session.write().await.replace_origin_account_binding(Some(binding)); - } - Ok(prepared_origin.preacquired_origin_account_handle) -} - -#[allow(clippy::too_many_lines)] -async fn prepare_hls_transient_origin_io_for_authorized_resource_work( - app_state: &Arc, - session: &HlsSessionHandle, - access_context: &HlsAccessContext, - fingerprint: &Fingerprint, - req_headers: &HeaderMap, - now_ms: u64, -) -> Result, HlsOriginRuntimeAcquireError> { - let hls_ctx = app_state.hls_ctx(); - let existing_binding = session.read().await.origin_account_binding.clone(); - let origin_policy = hls_effective_origin_acquire_policy(session).await; - let reservation_ttl_secs = hls_origin_account_reservation_ttl_secs_for_session(session).await; - if let Some(binding) = existing_binding.as_ref().filter(|binding| binding.is_active()) { - match hls_origin_account_status(&hls_ctx, binding) { - HlsOriginAccountStatus::Known => { - let origin_io = HlsOriginIoContext { - ctx: hls_ctx.clone(), - client_addr: fingerprint.addr, - allow_grace: HlsOriginWorkClass::Demand.allows_grace(), - priority: origin_policy.priority, - connection_kind: origin_policy.connection_kind, - reservation_ttl_secs, - preacquired_provider_handle: None, - started_generation: None, - }; - let started_generation = session.write().await.start_origin_work(); - if let Ok(lease_guard) = begin_hls_origin_account_io_bounded( - &origin_io, - session, - binding, - hls_object_body_deadline(app_state.hls_proxy.segment_fetch_policy().origin_segment_timeout_ms), - ) - .await - { - return Ok(Some(HlsTransientOriginIoGuard::new( - Arc::clone(session), - origin_io, - lease_guard, - started_generation, - ))); - } - session.write().await.finish_origin_work(started_generation); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - } - HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired => {} - } - } - - if !existing_binding - .as_ref() - .is_some_and(|binding| hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, binding)) - { - return Ok(None); - } - if session.read().await.activity.active_origin_work_count > 0 { - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - } - let request_context = resolve_hls_playback_manifest_request_context(app_state, access_context, req_headers) - .await - .map_err(HlsOriginRuntimeAcquireError::Fatal)?; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let prepared_origin = prepare_hls_origin_runtime( - app_state, - session, - &request_context.input, - &request_context.hls_url, - request_context.session_entry_url.as_str(), - &proxy_session_id, - fingerprint, - origin_policy.connection_kind, - origin_policy.priority, - HlsOriginWorkKind::Resource, - HlsOriginWorkClass::Demand, - now_ms, - ) - .await?; - if let Some(binding) = prepared_origin.origin_account_binding_to_store { - session.write().await.replace_origin_account_binding(Some(binding)); - } - let Some(provider_handle) = prepared_origin.preacquired_origin_account_handle else { - return Ok(None); - }; - let Some(binding) = session.read().await.origin_account_binding.clone().filter(HlsOriginAccountBinding::is_active) - else { - app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - let origin_io = HlsOriginIoContext { - ctx: hls_ctx, - client_addr: fingerprint.addr, - allow_grace: HlsOriginWorkClass::Demand.allows_grace(), - priority: origin_policy.priority, - connection_kind: origin_policy.connection_kind, - reservation_ttl_secs, - preacquired_provider_handle: None, - started_generation: None, - } - .with_preacquired_provider_handle(provider_handle); - let started_generation = session.write().await.start_origin_work(); - let Ok(lease_guard) = begin_hls_origin_account_io_bounded( - &origin_io, - session, - &binding, - hls_object_body_deadline(app_state.hls_proxy.segment_fetch_policy().origin_segment_timeout_ms), - ) - .await - else { - session.write().await.finish_origin_work(started_generation); - return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); - }; - Ok(Some(HlsTransientOriginIoGuard::new(Arc::clone(session), origin_io, lease_guard, started_generation))) -} - -struct HlsCachedManifestRead { - transient_body: Option, - rendered_body: Option, - should_wait: bool, - wait_for_initial_commit: bool, -} - -struct HlsCachedTransientManifestRead { - body: Arc, - template: Arc, - source_commit_identity: HlsManifestCommitIdentity, - window_policy: HlsManifestWindowPolicy, - finalized_manifest_generation: Option, - published_resource_ids: HlsPublishedTransientResourceIds, -} - -async fn read_hls_cached_manifest( - session: &HlsSessionHandle, - options: HlsCachedManifestOptions, - started_at_ms: u64, -) -> HlsCachedManifestRead { - let session = session.read().await; - let now_ms = current_time_millis(); - let should_wait = session.initial_manifest_commit_work_pending(); - let committed_body = hls_committed_manifest_body_for_request(&session, options, started_at_ms, now_ms); - let (transient_body, rendered_body) = match committed_body { - Some(HlsCommittedManifestBody::Transient(body)) => ( - session.transient.last_manifest_template().zip(session.transient.last_manifest_commit_identity()).map( - |(template, source_commit_identity)| HlsCachedTransientManifestRead { - body, - template, - source_commit_identity, - window_policy: session.transient.last_manifest_window_policy(), - finalized_manifest_generation: session.transient.current_finalized_manifest_generation(), - published_resource_ids: session.transient.last_manifest_published_resource_ids(), - }, - ), - None, - ), - Some(HlsCommittedManifestBody::Normal(body)) => (None, Some(body)), - None => (None, None), - }; - let wait_for_initial_commit = hls_should_wait_for_initial_manifest_commit( - &session, - transient_body.is_some() || rendered_body.is_some(), - should_wait, - options, - now_ms, - ); - HlsCachedManifestRead { transient_body, rendered_body, should_wait, wait_for_initial_commit } -} - -struct HlsCachedManifestViewContext<'a> { - proxy_session_id: &'a ProxySessionId, - access_lease_id: &'a HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &'a crate::model::StripConfig, - server_path: Option<&'a str>, - bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, -} - -#[derive(Clone, Copy)] -enum HlsRuntimeBandwidthLearningContext<'a> { - Disabled, - Eligible(&'a ConfigInput), -} - -impl HlsCachedManifestViewContext<'_> { - fn new<'a>( - proxy_session_id: &'a ProxySessionId, - access_lease_id: &'a HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &'a crate::model::StripConfig, - server_path: Option<&'a str>, - bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, - ) -> HlsCachedManifestViewContext<'a> { - HlsCachedManifestViewContext { - proxy_session_id, - access_lease_id, - access_lease_state, - strip, - server_path, - bandwidth_learning, - } - } - - fn materialize( - &self, - body: &str, - mode: &'static str, - window_policy: HlsManifestWindowPolicy, - ) -> HlsMaterializedSharedManifest { - materialize_shared_hls_access_manifest( - body, - self.access_lease_id, - self.access_lease_state, - self.strip, - window_policy, - mode, - self.server_path, - ) - } - - async fn finish( - &self, - app_state: &Arc, - session: &HlsSessionHandle, - materialized: HlsMaterializedSharedManifest, - strip_diagnostic: HlsInitialStripPublicationDiagnostic, - ) -> axum::response::Response { - touch_pending_manifest_follow_up_window(app_state, session, self.access_lease_id, self.access_lease_state) - .await; - drop(spawn_hls_runtime_bandwidth_persistence(app_state, session, self.bandwidth_learning)); - mark_successful_canonical_manifest_activity(app_state, session, current_time_millis()).await; - log_hls_initial_strip_publication(self.proxy_session_id, self.access_lease_id, strip_diagnostic); - hls_response(materialized.body).into_response() - } -} - -fn spawn_hls_runtime_bandwidth_persistence( - app_state: &Arc, - session: &HlsSessionHandle, - context: HlsRuntimeBandwidthLearningContext<'_>, -) -> Option> { - let input = match context { - HlsRuntimeBandwidthLearningContext::Disabled => return None, - HlsRuntimeBandwidthLearningContext::Eligible(input) => input.clone(), - }; - let (bitrate_bps, proxy_session_id, stream_ref) = { - let Ok(mut session_guard) = session.try_write() else { - return None; - }; - let bitrate_bps = session_guard.begin_bandwidth_persistence(current_time_millis())?; - (bitrate_bps, session_guard.proxy_session_id.clone(), session_guard.origin_source.stream_ref.clone()) - }; - let app_config = Arc::clone(&app_state.app_config); - let hls_proxy = Arc::clone(&app_state.hls_proxy); - let session = Arc::clone(session); - - Some(tokio::spawn(async move { - let outcome = match persist_input_live_bitrate_bps(&app_config, &input, &stream_ref, bitrate_bps).await { - Ok(repository_outcome) => hls_bandwidth_persistence_outcome(repository_outcome, &proxy_session_id), - Err(err) => { - error!( - "HLS runtime bandwidth persistence failed: proxy_session={} error={}", - safe_proxy_session_id(&proxy_session_id), - sanitize_sensitive_info(&err.to_string()) - ); - HlsBandwidthPersistenceOutcome::RetryAfter - } - }; - let Some(current_session) = hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await else { - return; - }; - if !Arc::ptr_eq(¤t_session, &session) { - return; - } - current_session.write().await.finish_bandwidth_persistence(bitrate_bps, outcome, current_time_millis()); - })) -} - -fn hls_bandwidth_persistence_outcome( - repository_outcome: LiveBitratePersistenceOutcome, - proxy_session_id: &ProxySessionId, -) -> HlsBandwidthPersistenceOutcome { - match repository_outcome { - LiveBitratePersistenceOutcome::Updated | LiveBitratePersistenceOutcome::AlreadyEqualOrHigher => { - HlsBandwidthPersistenceOutcome::Persisted - } - LiveBitratePersistenceOutcome::MissingDatabase => { - debug!( - "HLS runtime bandwidth persistence deferred: proxy_session={} reason=missing_database", - safe_proxy_session_id(proxy_session_id) - ); - HlsBandwidthPersistenceOutcome::RetryAfter - } - LiveBitratePersistenceOutcome::MissingStreamItem => { - debug!( - "HLS runtime bandwidth persistence deferred: proxy_session={} reason=missing_stream_item", - safe_proxy_session_id(proxy_session_id) - ); - HlsBandwidthPersistenceOutcome::RetryAfter - } - LiveBitratePersistenceOutcome::PermanentlyInapplicable(reason) => { - debug!( - "HLS runtime bandwidth persistence skipped: proxy_session={} reason={}", - safe_proxy_session_id(proxy_session_id), - reason.log_label() - ); - HlsBandwidthPersistenceOutcome::PermanentlyInapplicable - } - } -} - -fn hls_cached_manifest_temporarily_unavailable() -> axum::response::Response { - hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) -} - -fn observe_hls_lease_manifest_snapshot_derivation( - app_state: &AppState, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - derivation: Result, HlsManifestLimitViolation>, -) -> Result, ()> { - match derivation { - Ok(snapshot) => { - if let Some(snapshot) = snapshot.as_ref() { - app_state.hls_proxy.metrics().record_lease_snapshot_segments(snapshot.visible_segments.len()); - } - Ok(snapshot) - } - Err(violation) => { - app_state.hls_proxy.metrics().record_manifest_limit_rejection(); - warn!( - "HLS lease manifest snapshot rejected: proxy_session={} lease={} reason=manifest-representation-limit kind={} actual={} limit={}", - safe_proxy_session_id(proxy_session_id), - safe_hls_access_lease_id(access_lease_id), - violation.kind.as_log_value(), - violation.actual, - violation.limit - ); - Err(()) - } - } -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn try_hls_cached_manifest_response( - app_state: &Arc, - session: &HlsSessionHandle, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &crate::model::StripConfig, - server_path: Option<&str>, - options: HlsCachedManifestOptions, - bandwidth_learning: HlsRuntimeBandwidthLearningContext<'_>, -) -> Option { - let started_at = tokio::time::Instant::now(); - let started_at_ms = current_time_millis(); - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let Some(publication_guard) = app_state - .hls_proxy - .prepare_access_lease_manifest_publication(access_lease_id, &proxy_session_id, started_at_ms) - .await - else { - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - let view = HlsCachedManifestViewContext::new( - &proxy_session_id, - access_lease_id, - access_lease_state, - strip, - server_path, - bandwidth_learning, - ); - loop { - let cached = read_hls_cached_manifest(session, options, started_at_ms).await; - if !cached.wait_for_initial_commit { - let prepared = if let Some(transient) = cached.transient_body { - let materialized = view.materialize(&transient.body, "transient", transient.window_policy); - let published_resource_ids = if transient.window_policy.preserves_full_manifest() { - transient.published_resource_ids.clone() - } else { - HlsPublishedTransientResourceIds::from_manifest_body(&materialized.body) - }; - let delivered_at_ms = current_time_millis(); - let snapshot_input = if transient.window_policy.preserves_full_manifest() { - HlsLeaseManifestSnapshotInput::TransientPassthroughTemplate { - template: &transient.template, - source_commit_identity: transient.source_commit_identity, - uri_materialization: HlsLeaseManifestUriMaterialization::new( - access_lease_id, - normalize_hls_proxy_public_path_prefix(server_path).map(Arc::from), - ), - finalized_manifest_generation: transient.finalized_manifest_generation, - } - } else { - HlsLeaseManifestSnapshotInput::TransientPassthrough { - materialized_body: &materialized.body, - source_commit_identity: transient.source_commit_identity, - finalized_manifest_generation: transient.finalized_manifest_generation, - } - }; - let snapshot = observe_hls_lease_manifest_snapshot_derivation( - app_state, - &proxy_session_id, - access_lease_id, - derive_hls_lease_manifest_snapshot(&snapshot_input, delivered_at_ms), - ); - let Ok(snapshot) = snapshot else { - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - let Some(snapshot) = snapshot else { - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) - } else if let Some(body) = cached.rendered_body { - let materialized = view.materialize(&body, "normal", HlsManifestWindowPolicy::ApplyLiveWindow); - let published_resource_ids = HlsPublishedTransientResourceIds::from_manifest_body(&materialized.body); - let delivered_at_ms = current_time_millis(); - let snapshot = { - let session = session.read().await; - observe_hls_lease_manifest_snapshot_derivation( - app_state, - &proxy_session_id, - access_lease_id, - derive_hls_lease_manifest_snapshot( - &HlsLeaseManifestSnapshotInput::NormalCacheTimeline { - session: &session, - committed_body: &body, - materialized_body: &materialized.body, - stripped_tail_segments: stripped_tail_segments(&materialized), - }, - delivered_at_ms, - ), - ) - }; - let Ok(snapshot) = snapshot else { - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - let Some(snapshot) = snapshot else { - if access_lease_state != HlsAccessLeaseState::Pending { - return None; - } - if wait_for_hls_startup_evidence(started_at, options.wait_timeout).await { - continue; - } - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) - } else { - None - }; - if let Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) = prepared { - if access_lease_state == HlsAccessLeaseState::Pending - && !hls_startup_admission_allows_snapshot(&app_state.hls_ctx(), session, &snapshot, delivered_at_ms) - .await - { - if wait_for_hls_startup_evidence(started_at, options.wait_timeout).await { - continue; - } - return Some(hls_cached_manifest_temporarily_unavailable()); - } - let startup_snapshot = snapshot.clone(); - let admission_at_ms = current_time_millis(); - let outcome = app_state - .hls_proxy - .commit_access_lease_manifest_publication_with_resources( - access_lease_id, - &proxy_session_id, - publication_guard, - snapshot, - published_resource_ids, - admission_at_ms, - ) - .await; - if let Some(snapshot_generation) = outcome.snapshot_generation() { - let published_at_ms = current_time_millis(); - let first_startup_publication = - app_state.hls_proxy.startup_observability().record_manifest_publication( - access_lease_id, - snapshot_generation, - admission_at_ms, - published_at_ms, - Arc::from(startup_snapshot.visible_proxy_seqs().collect::>()), - ); - if first_startup_publication && hls_access_manifest_uses_startup_view(access_lease_state) { - app_state.hls_proxy.spawn_access_lease_repair_prewarm( - Arc::clone(session), - access_lease_id.clone(), - startup_snapshot, - snapshot_generation, - ); - } - } - let publication_status = if outcome.is_committed() { - HlsInitialStripPublicationStatus::Committed - } else { - HlsInitialStripPublicationStatus::NotCommitted - }; - let Some(strip_diagnostic) = - hls_initial_strip_publication_diagnostic(publication_status, access_lease_state, &materialized) - else { - return Some(hls_cached_manifest_temporarily_unavailable()); - }; - return Some(view.finish(app_state, session, materialized, strip_diagnostic).await); - } - } - if options.wait_timeout.is_zero() || !cached.should_wait || started_at.elapsed() >= options.wait_timeout { - return None; - } - let remaining = options.wait_timeout.saturating_sub(started_at.elapsed()); - tokio::time::sleep(remaining.min(HLS_MANIFEST_WAIT_POLL_INTERVAL)).await; - } -} - -async fn wait_for_hls_startup_evidence(started_at: tokio::time::Instant, wait_timeout: Duration) -> bool { - let elapsed = started_at.elapsed(); - if wait_timeout.is_zero() || elapsed >= wait_timeout { - return false; - } - tokio::time::sleep(wait_timeout.saturating_sub(elapsed).min(Duration::from_millis(25))).await; - true -} - -async fn mark_successful_canonical_manifest_activity( - app_state: &Arc, - session: &HlsSessionHandle, - now_ms: u64, -) { - mark_hls_authorized_media_access(app_state, session, now_ms).await; -} - -async fn mark_hls_authorized_manifest_access(app_state: &Arc, session: &HlsSessionHandle, now_ms: u64) { - session.write().await.mark_authorized_manifest_access(now_ms); - app_state.hls_proxy.schedule_session_idle_for_handle(session).await; -} - -async fn mark_hls_authorized_media_access(app_state: &Arc, session: &HlsSessionHandle, now_ms: u64) { - session.write().await.mark_authorized_media_access(now_ms); - app_state.hls_proxy.schedule_session_idle_for_handle(session).await; -} - -fn hls_cache_configured(app_state: &Arc) -> bool { - let config = app_state.app_config.config.load(); - config.reverse_proxy.as_ref().is_some_and(|reverse_proxy| reverse_proxy.hls_cache.is_some()) -} - -fn hls_cache_enabled_for_target(app_state: &Arc, target: &ConfigTarget) -> bool { - hls_cache_configured(app_state) && is_hls_stream_share_enabled(target) -} - -pub(in crate::api) fn build_virtual_hls_entry_path( - target: &ConfigTarget, - input: &ConfigInput, - user: &ProxyUserCredentials, - virtual_id: u32, -) -> String { - if input.input_type.is_m3u() && !target.has_output(TargetType::Xtream) { - format!("/{}/live/{}/{}/{}{HLS_EXT}", storage_const::M3U_STREAM_PATH, user.username, user.password, virtual_id) - } else { - format!("/live/{}/{}/{}{HLS_EXT}", user.username, user.password, virtual_id) - } -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub(in crate::api) async fn handle_hls_stream_request( - fingerprint: &Fingerprint, - app_state: &Arc, - user: &ProxyUserCredentials, - target: &ConfigTarget, - user_session: Option<&UserSession>, - session_token_hint: Option<&str>, - hls_url: &str, - archive_reference: Option, - stream_context: HlsEntryStreamContext, - input: &ConfigInput, - req_headers: &HeaderMap, - connection_permission: UserConnectionPermission, - connection_kind: Option, - original_hls_entry_path: &str, -) -> impl IntoResponse + Send { - let virtual_id = stream_context.virtual_id(); - if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await { - return axum::http::StatusCode::BAD_REQUEST.into_response(); - } - - let stream_ref = stream_context.stream_ref().to_string(); - let normalized_hls_url = normalize_xtream_live_hls_url(hls_url, input); - if normalized_hls_url != hls_url { - debug_if_enabled!( - "Normalized xtream hls url from {} to {}", - sanitize_sensitive_info(hls_url), - sanitize_sensitive_info(&normalized_hls_url) - ); - } - let url = ensure_hls_manifest_extension(&normalized_hls_url); - // Recover archive context when callers (esp. Xtream timeshift) pass None but the - // resolved provider URL / catchup session still carries Flussonic archive markers. - let archive_reference = archive_reference.or_else(|| m3u_archive_epg_reference_ts(&url)).or_else(|| { - user_session - .map(|session| session.token.as_str()) - .or(session_token_hint) - .and_then(m3u_catchup_epg_reference_from_session_token) - }); - let hls_cache_origin = build_hls_origin_resolution(input, &url); - let hls_origin_source = hls_cache_origin.as_ref().map(|_| { - build_hls_origin_source_for_playback(input, stream_ref.clone(), archive_reference, Some(url.as_str())) - }); - let server_info = app_state.app_config.get_user_server_info(user); - let Some(server_info) = server_info else { - return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - - let disabled_headers = app_state.get_disabled_headers(); - let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); - let headers = build_hls_manifest_request_headers( - &input.headers, - req_headers, - disabled_headers.as_ref(), - default_user_agent.as_deref(), - stream_context.identity().upstream_user_agent(), - ); - - if hls_cache_enabled_for_target(app_state, target) { - let Some(origin_source) = hls_origin_source.clone() else { - return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - return create_hls_cache_entry_master_playlist_response( - app_state, - fingerprint, - user, - origin_source, - virtual_id, - user_session, - stream_context.known_bitrate_bps(), - session_token_hint, - if archive_reference.is_some() { - url.as_str() - } else { - hls_cache_origin.as_ref().map_or(url.as_str(), |origin| origin.session_entry_url.as_str()) - }, - input, - connection_permission, - connection_kind, - server_info.path.as_deref(), - ) - .await; - } - - let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); - let (request_url, session_token, provider_handle, _selected_provider_config) = if let Some(session) = user_session { - let pinned_provider = if session.provider.is_empty() { &input.name } else { &session.provider }; - let provider_handle = if let Some(handle) = app_state - .active_provider - .acquire_exact_connection_with_grace_for_session( - pinned_provider, - &fingerprint.addr, - false, - connection_priority_for_kind( - user, - session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal), - ), - session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal), - Some(session.token.as_str()), - ) - .await - { - Some(handle) - } else { - debug_if_enabled!( - "HLS pinned provider {} unavailable for {}; aborting allocation to prevent mid-session migration", - sanitize_sensitive_info(pinned_provider), - sanitize_sensitive_info(&fingerprint.addr.to_string()) - ); - None - }; - - if provider_handle.is_none() { - return hls_panel_provisioning_or_status_response( - app_state, - user, - input, - virtual_id, - original_hls_entry_path, - server_info.path.as_deref(), - StatusCode::SERVICE_UNAVAILABLE, - ) - .await; - } - match provider_handle.as_ref().map(|handle| &handle.allocation) { - Some(ProviderAllocation::Exhausted) => (url, None, provider_handle, None), - Some(ProviderAllocation::Available(cfg) | ProviderAllocation::GracePeriod(cfg)) => { - let selected_provider_config = Arc::clone(cfg); - let Some(stream_url) = get_stream_alternative_url(&url, input, cfg) else { - app_state.connection_manager.release_provider_handle(provider_handle).await; - return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - let session_token = app_state - .active_users - .create_user_session(crate::api::model::CreateUserSessionParams { - user, - session_token: &session.token, - virtual_id, - provider: &cfg.name, - stream_url: &stream_url, - addr: &fingerprint.addr, - connection_permission, - connection_kind: session.connection_kind.or(connection_kind), - socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), - }) - .await; - let hls_session_ttl_secs = get_hls_session_ttl_secs(app_state); - app_state - .active_provider - .refresh_provider_reservation(&cfg.name, &session_token, hls_session_ttl_secs) - .await; - (stream_url, Some(session_token), provider_handle, Some(selected_provider_config)) - } - None => (url, None, None, None), - } - } else { - // Append/shift catchup must keep an m3u-catchup session token even when shared HLS - // cache is off; otherwise rewritten segments register as LiveHls in the panel. - let user_session_token = hls_entry_user_session_token( - fingerprint, - &user.username, - virtual_id, - session_token_hint, - archive_reference, - ); - let hls_session_owner = if hls_cache_enabled_for_target(app_state, target) { - let session_key = HlsSessionKey::new(input.id, stream_context.stream_ref()); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - Some(build_hls_origin_session_owner(&proxy_session_id)) - } else { - None - }; - let session_owner = hls_session_owner.as_deref().unwrap_or(user_session_token.as_str()); - let hls_session_ttl_secs = get_hls_session_ttl_secs(app_state); - let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( - app_state, - fingerprint, - user, - input, - virtual_id, - &url, - &user_session_token, - session_owner, - hls_session_ttl_secs, - connection_permission, - fallback_connection_kind, - true, - ) - .await - else { - return hls_panel_provisioning_or_status_response( - app_state, - user, - input, - virtual_id, - original_hls_entry_path, - server_info.path.as_deref(), - StatusCode::SERVICE_UNAVAILABLE, - ) - .await; - }; - debug_if_enabled!( - "API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}", - sanitize_sensitive_info(&user.username), - reservation.selected_provider_config.as_ref().map_or("", |provider| provider.name.as_ref()), - sanitize_sensitive_info(&reservation.request_url) - ); - ( - reservation.request_url, - Some(reservation.session_token), - reservation.provider_handle, - reservation.selected_provider_config, - ) - }; - - // Playlist requests only need the chosen provider account to derive the URL and pin the session. - // Holding the provider slot until the first segment request causes stale active connections and - // breaks forced same-account reuse on the next HLS/Catchup stream request. - app_state.connection_manager.release_provider_handle(provider_handle).await; - - let input_source = InputSource::from(input).with_url(request_url); - let download_result = download_legacy_hls_manifest(app_state, &input_source, &headers).await; - match download_result { - 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 { - secret: &encrypt_secret, - base_url: &base_url, - content: &content, - hls_url: response_url, - target_id: target.id, - virtual_id, - input_id: input.id, - user_token: session_token.as_deref(), - }; - 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() - } - Err(err) => { - error!("Failed to download m3u8: {}", request::text_response_error_log_label(&err)); - if let Some(session_token) = session_token.as_deref() { - terminate_failed_hls_manifest_session(app_state, &user.username, session_token).await; - } - - hls_custom_video_manifest_response( - app_state, - user, - CustomVideoStreamType::ChannelUnavailable, - StatusCode::NOT_FOUND, - ) - .await - } - } -} - -async fn get_stream_channel( - app_state: &Arc, - target: &Arc, - virtual_id: u32, -) -> Option { - if target.has_output(TargetType::Xtream) { - if let Ok(pli) = - xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None).await - { - return Some(pli.to_stream_channel(target.id)); - } - } - let target_id = target.id; - m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) - .await - .ok() - .map(|pli| pli.to_stream_channel(target_id)) -} - -fn hls_stream_context_or_unavailable( - item: &impl PlaylistEntry, - virtual_id: u32, -) -> Result { - HlsEntryStreamContext::from_playlist_item(item).ok_or_else(|| { - warn!("HLS input stream identity missing for virtual_id={virtual_id}; refresh target playlist"); - StatusCode::SERVICE_UNAVAILABLE - }) -} - -pub(in crate::api) async fn resolve_hls_virtual_source_for_target( - app_state: &Arc, - target: &Arc, - virtual_id: u32, -) -> Result { - let (input_name, stream_context) = if target.has_output(TargetType::Xtream) { - if let Ok(item) = - xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None).await - { - let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; - (Arc::clone(&item.input_name), stream_context) - } else { - let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; - (Arc::clone(&item.input_name), stream_context) - } - } else { - let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; - (Arc::clone(&item.input_name), stream_context) - }; - let input = app_state.app_config.get_input_by_name(&input_name).ok_or(StatusCode::NOT_FOUND)?; - Ok(HlsResolvedVirtualSource { input, stream_context }) -} - -async fn resolve_hls_origin_playlist_url( - app_state: &Arc, - target: &Arc, - input: &ConfigInput, - virtual_id: u32, - fallback_url: &str, -) -> Result { - if input.input_type.is_xtream() && target.has_output(TargetType::Xtream) { - let pli = xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - let hls_extension = format!(".{HLS_EXT}"); - let (query_path, _) = get_query_path("", Some(&hls_extension), &pli, app_state); - return get_xtream_player_api_stream_url(input, ApiStreamContext::Live, &query_path, &pli.url) - .map(|url| url.to_string()) - .ok_or(StatusCode::SERVICE_UNAVAILABLE); - } - - Ok(fallback_url.to_string()) -} - -async fn resolve_stream_channel( - app_state: &Arc, - target: &Arc, - input: &Arc, - virtual_id: u32, - hls_url: &str, - archive_reference: Option, - session_token: Option<&str>, -) -> StreamChannel { - let unknown = "Unknown".intern(); - let mut channel = match get_stream_channel(app_state, target, virtual_id).await { - Some(mut channel) => { - channel.url = Arc::from(hls_url); - channel - } - None => StreamChannel { - target_id: target.id, - virtual_id, - provider_id: 0, - input_name: Arc::clone(&input.name), - item_type: PlaylistItemType::LiveHls, - cluster: XtreamCluster::Live, - group: unknown.clone(), - title: unknown, - url: Arc::from(hls_url), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - }, - }; - - let archive_reference = archive_reference.or_else(|| epg_reference_ts_from_date_tree_path(hls_url)); - // Append/shift catchup often loses utc/utcstart on rewritten segment URLs; the session - // token still identifies archive playback for Streams/History (Catchup, not Live/HLS). - let is_archive_playback = archive_reference.is_some() - || looks_like_archive_media_path(hls_url) - || session_token.is_some_and(is_m3u_catchup_session_token); - if is_archive_playback { - channel.item_type = PlaylistItemType::Catchup; - channel.cluster = XtreamCluster::Video; - channel.epg_reference_ts = archive_reference; - } else { - channel.item_type = PlaylistItemType::LiveHls; - channel.epg_reference_ts = None; - } - channel -} - -fn hls_entry_user_session_token( - fingerprint: &Fingerprint, - username: &str, - virtual_id: u32, - session_token_hint: Option<&str>, - archive_reference: Option, -) -> String { - if let Some(hint) = session_token_hint.filter(|token| is_m3u_catchup_session_token(token)) { - return hint.to_string(); - } - if let Some(timestamp) = archive_reference { - return create_m3u_catchup_session_key(fingerprint, username, virtual_id, &format!("archive|{timestamp}|0")); - } - create_playback_session_fingerprint(fingerprint, username, virtual_id, PlaylistItemType::LiveHls, None) -} - -struct HlsAccessManifestRequestContext { - input: Arc, - hls_url: String, - session_entry_url: HlsOriginEntryUrl, - original_hls_entry_path: String, - origin_source: HlsOriginSource, - headers: HeaderMap, - server_path: Option, -} - -async fn resolve_hls_playback_manifest_request_context( - app_state: &Arc, - access_context: &HlsAccessContext, - req_headers: &HeaderMap, -) -> Result { - let Some((user, target)) = app_state.app_config.get_target_for_username(&access_context.username) else { - return Err(StatusCode::NOT_FOUND); - }; - if !hls_cache_enabled_for_target(app_state, &target) { - return Err(StatusCode::NOT_FOUND); - } - let Some(input) = app_state.app_config.get_input_by_id(access_context.input_id) else { - return Err(StatusCode::NOT_FOUND); - }; - if app_state - .active_users - .is_user_blocked_for_stream(&user.username, VirtualId::new(access_context.virtual_id)) - .await - { - return Err(StatusCode::FORBIDDEN); - } - let Some(channel) = get_stream_channel(app_state, &target, access_context.virtual_id).await else { - return Err(StatusCode::NOT_FOUND); - }; - let origin_playlist_url = if let Some(archive_url) = access_context.archive_origin_url.as_ref() { - archive_url.clone() - } else { - resolve_hls_origin_playlist_url(app_state, &target, &input, access_context.virtual_id, channel.url.as_ref()) - .await? - }; - let Some(hls_cache_origin) = build_hls_origin_resolution(&input, &origin_playlist_url) else { - return Err(StatusCode::SERVICE_UNAVAILABLE); - }; - let origin_source = build_hls_origin_source_for_playback( - &input, - access_context.stream_ref.clone(), - access_context.epg_reference_ts, - Some(&origin_playlist_url), - ); - let Some(server_info) = app_state.app_config.get_user_server_info(&user) else { - return Err(StatusCode::SERVICE_UNAVAILABLE); - }; - let disabled_headers = app_state.get_disabled_headers(); - let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); - let headers = build_hls_manifest_request_headers( - &input.headers, - req_headers, - disabled_headers.as_ref(), - default_user_agent.as_deref(), - channel.upstream_user_agent.as_deref(), - ); - - let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, access_context.virtual_id); - - Ok(HlsAccessManifestRequestContext { - input, - hls_url: hls_cache_origin.hls_url, - session_entry_url: hls_cache_origin.session_entry_url, - original_hls_entry_path, - origin_source, - headers, - server_path: server_info.path.clone(), - }) -} - -async fn hls_manifest_preflight_refresh_ordering( - app_state: &Arc, - session: &HlsSessionHandle, - lease: &HlsAccessLease, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - now_ms: u64, -) -> Result> { - match hls_manifest_terminal_preflight(session, lease, now_ms).await { - HlsManifestTerminalPreflight::ServeCommittedPlayback => { - Err(Box::new(hls_terminal_playback_response(lease, proxy_session_id, access_lease_id).unwrap_or_else( - || hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::RuntimeUnavailable), - ))) - } - HlsManifestTerminalPreflight::BootstrapPendingLease => Ok(HlsManifestRefreshOrdering::Background), - HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation => { - Ok(HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation) - } - HlsManifestTerminalPreflight::EvaluateTerminal => resolve_hls_terminal_manifest_state( - app_state, - session, - proxy_session_id, - access_lease_id, - lease.clone(), - now_ms, - ) - .await - .map(|_| HlsManifestRefreshOrdering::Background), - HlsManifestTerminalPreflight::FailClosed { reason } => { - Err(Box::new(hls_terminal_failed_closed_response(reason))) - } - } -} - -async fn hls_proxy_manifest( - fingerprint: Fingerprint, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, - headers: HeaderMap, -) -> axum::response::Response { - let proxy_session_id = ProxySessionId(params.proxy_session_id); - let access_lease_id = HlsAccessLeaseId(params.hls_access_lease_id); - let now_ms = current_time_millis(); - let access_lease_snapshot = - app_state.hls_proxy.access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms).await; - if let Some(lease) = access_lease_snapshot.as_ref() { - let standalone_policy_response_required = lease.playback_mode == HlsLeasePlaybackMode::Ended - && lease - .runtime_policy_denial_reason() - .is_some_and(HlsRuntimeCustomTailReason::permits_unpublished_lease_standalone_tail); - if let (false, Some(response)) = ( - standalone_policy_response_required, - hls_terminal_playback_response(lease, &proxy_session_id, &access_lease_id), - ) { - return response; - } - } - let session = app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await; - if let Some(session) = session.as_ref() { - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - session, - &proxy_session_id, - now_ms, - ) - .await; - } - let (access_context, access_lease_state) = match hls_manifest_access_context_and_state( - &app_state, - &fingerprint, - &proxy_session_id, - &access_lease_id, - access_lease_snapshot.as_ref(), - now_ms, - ) - .await - { - Ok(context_and_state) => context_and_state, - Err(response) => return *response, - }; - let refresh_ordering = if let (Some(session), Some(lease)) = (session.as_ref(), access_lease_snapshot.as_ref()) { - match hls_manifest_preflight_refresh_ordering( - &app_state, - session, - lease, - &proxy_session_id, - &access_lease_id, - now_ms, - ) - .await - { - Ok(ordering) => ordering, - Err(response) => return *response, - } - } else { - HlsManifestRefreshOrdering::Background - }; - let request_context = - match resolve_hls_playback_manifest_request_context(&app_state, &access_context, &headers).await { - Ok(context) => context, - Err(status) => return hls_canonical_status_response(status), - }; - - try_hls_cache_canonical_manifest_response( - &app_state, - &fingerprint, - &access_context, - &proxy_session_id, - &access_lease_id, - access_lease_state, - HlsCacheManifestOrigin { - raw_request_url: &request_context.hls_url, - session_entry_url: request_context.session_entry_url.clone(), - input: &request_context.input, - origin_source: request_context.origin_source, - }, - request_context.headers, - request_context.server_path.as_deref(), - &request_context.original_hls_entry_path, - refresh_ordering, - ) - .await - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()) -} - -#[allow(clippy::too_many_lines)] -async fn hls_api_stream( - fingerprint: Fingerprint, - req_headers: HeaderMap, - axum::extract::RawQuery(raw_query): axum::extract::RawQuery, - axum::extract::Path(params): axum::extract::Path, - axum::extract::State(app_state): axum::extract::State>, -) -> impl IntoResponse + Send { - let api_proxy_user = create_api_proxy_user(&app_state); - let (user, target) = if params.username == api_proxy_user.username - && crate::auth::constant_time_eq(params.password.as_bytes(), api_proxy_user.password.as_bytes()) - { - let Some(target) = app_state.app_config.get_target_by_id(params.target_id) else { - return axum::http::StatusCode::BAD_REQUEST.into_response(); - }; - (Arc::new(api_proxy_user), target) - } else { - let Some((user, target)) = app_state.app_config.get_target_for_user(¶ms.username, ¶ms.password) else { - // Credential failure is an auth error, not a malformed request - return app_state.app_config.get_auth_error_status().into_response(); - }; - if target.id != params.target_id { - return axum::http::StatusCode::BAD_REQUEST.into_response(); - } - (user, target) - }; - - // Nested path = relative origin segment that leaked past rewrite_hls (e.g. dvr-YYYY/...). - if params.token.contains('/') { - let Some((token, relative_path)) = params.token.split_once('/') else { - return StatusCode::BAD_REQUEST.into_response(); - }; - let encrypt_secret = app_state.get_encrypt_secret(); - let Some(decoded_hls_token) = get_hls_session_token_and_url_from_token(&encrypt_secret, token) else { - return StatusCode::BAD_REQUEST.into_response(); - }; - let lookup_session_token = decoded_hls_token - .0 - .clone() - .unwrap_or_else(|| create_session_fingerprint(&fingerprint, &user.username, params.stream_id, false)); - let Some(input) = app_state.app_config.get_input_by_id(params.input_id) else { - return StatusCode::BAD_REQUEST.into_response(); - }; - let Some(session) = app_state - .active_users - .find_latest_session_for_target_stream( - &user.username, - target.id, - input.name.as_ref(), - params.stream_id, - lookup_session_token.as_str(), - ) - .await - else { - return StatusCode::NOT_FOUND.into_response(); - }; - if !legacy_hls_route_allowed_with_cache( - hls_cache_enabled_for_target(&app_state, &target), - decoded_hls_token.0.as_deref(), - Some(session.token.as_str()), - ) { - return hls_custom_video_manifest_response_for_username( - &app_state, - &user.username, - CustomVideoStreamType::ChannelUnavailable, - StatusCode::NOT_FOUND, - ) - .await; - } - return hls_api_stream_leaked_relative( - fingerprint, - req_headers, - app_state, - user, - target, - input, - params.stream_id, - session, - decoded_hls_token.1, - relative_path.to_string(), - raw_query.as_deref(), - ) - .await; - } - - hls_api_stream_resolved( - fingerprint, - req_headers, - app_state, - user, - target, - params.input_id, - params.stream_id, - params.token, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn admit_recovered_archive_stream( - app_state: &Arc, - fingerprint: &Fingerprint, - user: &Arc, - req_headers: &HeaderMap, - input: &Arc, - mut session: UserSession, - stream_channel: StreamChannel, -) -> Result<(UserSession, StreamChannel, Option), Box> { - if session.permission == UserConnectionPermission::Exhausted { - return Err(Box::new( - hls_admission_failure_manifest_response( - app_state, - fingerprint, - user, - stream_channel, - session.provider.clone(), - req_headers, - ConnectFailureReason::UserConnectionsExhausted, - ) - .await, - )); - } - if app_state.active_provider.is_over_limit(&session.provider).await { - return Err(Box::new( - hls_admission_failure_manifest_response( - app_state, - fingerprint, - user, - stream_channel, - session.provider.clone(), - req_headers, - ConnectFailureReason::ProviderConnectionsExhausted, - ) - .await, - )); - } - let (connection_admission, grace_mode, _) = crate::api::api_utils::resolve_playback_request_admission( - &app_state.admission_ctx(), - user, - fingerprint, - Some(&session), - &session.token, - true, - crate::api::api_utils::EvictionReentryGuard::Session(&session.token), - false, - false, - ) - .await; - let connection_permission = connection_admission.permission; - let connection_kind = connection_admission.kind.or(session.connection_kind); - session.permission = connection_permission; - if let Some(connection_kind) = connection_kind { - session.connection_kind = Some(connection_kind); - } - if connection_permission == UserConnectionPermission::Exhausted - || (connection_permission == UserConnectionPermission::GracePeriod && connection_kind.is_none()) - { - let provider = if session.provider.is_empty() { input.name.clone() } else { session.provider.clone() }; - return Err(Box::new( - hls_admission_failure_manifest_response( - app_state, - fingerprint, - user, - stream_channel, - provider, - req_headers, - ConnectFailureReason::UserConnectionsExhausted, - ) - .await, - )); - } - Ok((session, stream_channel, grace_mode)) -} - -#[allow(clippy::too_many_arguments)] -async fn hls_api_stream_leaked_relative( - fingerprint: Fingerprint, - req_headers: HeaderMap, - app_state: Arc, - user: Arc, - target: Arc, - input: Arc, - stream_id: u32, - mut session: UserSession, - session_stream_url: String, - relative_path: String, - request_query: Option<&str>, -) -> axum::response::Response { - if let Err(e) = check_network_access_only(&user, &fingerprint, &app_state.app_config, &app_state.geoip) { - return e.into_player_response(app_state.app_config.get_auth_error_status()); - } - let Some(origin_url) = resolve_leaked_hls_relative_origin(&session_stream_url, &relative_path, request_query) - else { - return StatusCode::NOT_FOUND.into_response(); - }; - let archive_reference = resolve_m3u_archive_reference(&origin_url, Some(session.token.as_str())) - .or_else(|| epg_reference_ts_from_date_tree_path(&relative_path)) - .or_else(|| epg_reference_ts_from_date_tree_path(&origin_url)); - let is_archive_media = looks_like_archive_media_path(&relative_path) || looks_like_archive_media_path(&origin_url); - session.stream_url = origin_url.intern(); - let mut stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - stream_id, - &session.stream_url, - archive_reference, - Some(session.token.as_str()), - ) - .await; - // Leaked DVR/date-tree segments are always archive playback for the panel, even when the - // prior session was live and the date-tree timestamp could not be parsed. - if is_archive_media { - stream_channel.item_type = PlaylistItemType::Catchup; - stream_channel.cluster = XtreamCluster::Video; - if stream_channel.epg_reference_ts.is_none() { - stream_channel.epg_reference_ts = archive_reference; - } - } - let (session, stream_channel, grace_mode) = match admit_recovered_archive_stream( - &app_state, - &fingerprint, - &user, - &req_headers, - &input, - session, - stream_channel, - ) - .await - { - Ok(admission) => admission, - Err(response) => return *response, - }; - force_provider_stream_response( - &fingerprint, - &app_state, - &session, - stream_channel, - crate::api::api_utils::ForceStreamRequestContext { - req_headers: &req_headers, - input: &input, - user: &user, - session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), - content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, - }, - grace_mode, - ) - .await - .into_response() -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn hls_api_stream_resolved( - fingerprint: Fingerprint, - req_headers: HeaderMap, - app_state: Arc, - user: Arc, - target: Arc, - input_id: u16, - stream_id: u32, - token: String, -) -> axum::response::Response { - // Network access check only - permission check is done later with full stream info - if let Err(e) = check_network_access_only(&user, &fingerprint, &app_state.app_config, &app_state.geoip) { - return e.into_player_response(app_state.app_config.get_auth_error_status()); - } - let target_name = &target.name; - let virtual_id = stream_id; - let input = try_option_bad_request!( - app_state.app_config.get_input_by_id(input_id), - true, - format!("Can't find input {} for target {target_name}, stream_id {virtual_id}, hls", input_id) - ); - - if user.permission_denied(&app_state.app_config) { - let stream_channel = resolve_stream_channel(&app_state, &target, &input, virtual_id, "", None, None).await; - return hls_admission_failure_manifest_response( - &app_state, - &fingerprint, - &user, - stream_channel, - input.name.clone(), - &req_headers, - ConnectFailureReason::UserAccountExpired, - ) - .await; - } - - debug_if_enabled!("ID chain for hls endpoint: request_stream_id={stream_id} -> virtual_id={virtual_id}"); - let encrypt_secret = app_state.get_encrypt_secret(); - let Some(decoded_hls_token) = get_hls_session_token_and_url_from_token(&encrypt_secret, &token) else { - return axum::http::StatusCode::BAD_REQUEST.into_response(); - }; - let lookup_session_token = decoded_hls_token - .0 - .clone() - .unwrap_or_else(|| create_session_fingerprint(&fingerprint, &user.username, virtual_id, false)); - let mut user_session = - app_state.active_users.get_and_update_user_session(&user.username, &lookup_session_token).await; - if !legacy_hls_route_allowed_with_cache( - hls_cache_enabled_for_target(&app_state, &target), - decoded_hls_token.0.as_deref(), - user_session.as_ref().map(|session| session.token.as_str()), - ) { - return hls_manifest_channel_unavailable_response_for_username(&app_state, &user.username).await; - } - - if let Some(session) = &mut user_session { - let decoded_archive_reference = - resolve_m3u_archive_reference(&decoded_hls_token.1, Some(lookup_session_token.as_str())); - if session.permission == UserConnectionPermission::Exhausted { - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &decoded_hls_token.1, - decoded_archive_reference, - Some(session.token.as_str()), - ) - .await; - return hls_admission_failure_manifest_response( - &app_state, - &fingerprint, - &user, - stream_channel, - session.provider.clone(), - &req_headers, - ConnectFailureReason::UserConnectionsExhausted, - ) - .await; - } - - if app_state.active_provider.is_over_limit(&session.provider).await { - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &decoded_hls_token.1, - decoded_archive_reference, - Some(session.token.as_str()), - ) - .await; - return hls_admission_failure_manifest_response( - &app_state, - &fingerprint, - &user, - stream_channel, - session.provider.clone(), - &req_headers, - ConnectFailureReason::ProviderConnectionsExhausted, - ) - .await; - } - - let hls_url = match decoded_hls_token { - (Some(session_token), hls_url) if session.token.eq(&session_token) => hls_url, - (None, hls_url) => hls_url, - _ => return axum::http::StatusCode::BAD_REQUEST.into_response(), - }; - let hls_url = hls_url.intern(); - // Recover utc/utcstart from the prior playlist URL before overwriting with a segment URL - // that usually drops append/shift query params. - let archive_reference = resolve_m3u_archive_reference(&hls_url, Some(session.token.as_str())) - .or_else(|| m3u_archive_epg_reference_ts(session.stream_url.as_ref())); - session.stream_url = hls_url.clone(); - if session.virtual_id == virtual_id { - app_state.connection_manager.touch_http_activity(&user.username, &session.token, &fingerprint.addr).await; - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &hls_url, - archive_reference, - Some(session.token.as_str()), - ) - .await; - if is_seekable_media_request(stream_channel.cluster, &req_headers, extract_extension_from_url(&hls_url)) { - // partial request means we are in reverse proxy mode, seek happened - return force_provider_stream_response( - &fingerprint, - &app_state, - session, - stream_channel, - crate::api::api_utils::ForceStreamRequestContext { - req_headers: &req_headers, - input: &input, - user: &user, - session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), - content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, - }, - None, - ) - .await - .into_response(); - } - } else { - return axum::http::StatusCode::BAD_REQUEST.into_response(); - } - - let (connection_admission, grace_mode, request_class) = - crate::api::api_utils::resolve_playback_request_admission( - &app_state.admission_ctx(), - &user, - &fingerprint, - Some(session), - &session.token, - true, - crate::api::api_utils::EvictionReentryGuard::Session(&session.token), - // HLS playlist requests (.m3u8) are explicit Prepare: they set up session metadata - // but do not consume an admission slot. Segment and other media requests use Activate. - is_hls_url(&hls_url), - false, - ) - .await; - let connection_permission = connection_admission.permission; - let connection_kind = connection_admission.kind.or(session.connection_kind); - session.permission = connection_permission; - if let Some(connection_kind) = connection_kind { - session.connection_kind = Some(connection_kind); - } - if connection_permission == UserConnectionPermission::Exhausted - || (connection_permission == UserConnectionPermission::GracePeriod && connection_kind.is_none()) - { - let provider = if session.provider.is_empty() { input.name.clone() } else { session.provider.clone() }; - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &session.stream_url, - archive_reference, - Some(session.token.as_str()), - ) - .await; - return hls_admission_failure_manifest_response( - &app_state, - &fingerprint, - &user, - stream_channel, - provider, - &req_headers, - ConnectFailureReason::UserConnectionsExhausted, - ) - .await; - } - let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); - - if is_hls_url(&session.stream_url) { - let source = match resolve_hls_virtual_source_for_target(&app_state, &target, virtual_id).await { - Ok(source) if source.input.id == input.id => source, - Ok(source) => { - warn!( - "HLS input context mismatch for virtual_id={virtual_id}: expected_input_id={}, resolved_input_id={}", - input.id, source.input.id - ); - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - } - Err(status) => return status.into_response(), - }; - let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id); - return handle_hls_stream_request( - &fingerprint, - &app_state, - &user, - &target, - Some(session), - None, - &session.stream_url, - archive_reference, - source.stream_context, - &input, - &req_headers, - connection_permission, - connection_kind, - &original_hls_entry_path, - ) - .await - .into_response(); - } - - if is_file_url(&session.stream_url) { - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &hls_url, - archive_reference, - Some(session.token.as_str()), - ) - .await; - return local_stream_response( - &fingerprint, - &app_state, - stream_channel, - &req_headers, - &input, - &target, - &user, - connection_permission, - fallback_connection_kind, - Some(&session.token), - Some(request_class), - false, - ) - .await - .into_response(); - } - - let stream_channel = resolve_stream_channel( - &app_state, - &target, - &input, - virtual_id, - &hls_url, - archive_reference, - Some(session.token.as_str()), - ) - .await; - force_provider_stream_response( - &fingerprint, - &app_state, - session, - stream_channel, - crate::api::api_utils::ForceStreamRequestContext { - req_headers: &req_headers, - input: &input, - user: &user, - session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), - content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, - }, - grace_mode, - ) - .await - .into_response() - } else { - axum::http::StatusCode::BAD_REQUEST.into_response() - } -} - -pub fn hls_api_register() -> axum::Router> { - axum::Router::new() - .route( - "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/manifest.m3u8", - axum::routing::get(hls_proxy_manifest), - ) - .route( - "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/terminal/{generation}/{terminal_file}", - axum::routing::get(hls_proxy_terminal_segment), - ) - .route( - "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/{segment_file}", - axum::routing::get(hls_proxy_segment), - ) - .route( - "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/map/{map_file}", - axum::routing::get(hls_proxy_map), - ) - .route( - "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/r/{resource_file}", - axum::routing::get(hls_proxy_resource), - ) - .route( - "/hls/{username}/{password}/{target_id}/{input_id}/{stream_id}/{*token}", - axum::routing::get(hls_api_stream), - ) - //cfg.service(web::resource("/hls/{token}/{stream}").route(web::get().to(xtream_player_api_hls_stream))); - //cfg.service(web::resource("/play/{token}/{type}").route(web::get().to(xtream_player_api_play_stream))); -} - -#[cfg(test)] -mod tests { - use super::{ - super::hls_terminal_response::{ - hls_manifest_terminal_preflight, hls_terminal_endpoint_action, hls_terminal_failed_closed_response, - HlsManifestTerminalPreflight, HlsTerminalEndpointAction, - }, - build_hls_manifest_request_headers, extract_hls_provider_session_headers, hls_api_register, - hls_availability_reevaluation_registration_failure_response, hls_canonical_owner_registration, - hls_temporary_resource_unavailable_response, m3u_archive_epg_reference_ts, - m3u_catchup_epg_reference_from_session_token, resolve_leaked_hls_relative_origin, - HlsCanonicalOwnerRegistration, HlsCanonicalOwnerRegistrationFailure, HlsCanonicalOwnerRegistrationKind, - MAX_HLS_MANIFEST_BYTES, - }; - use crate::{ - api::model::{ - begin_hls_origin_account_io, build_hls_custom_video_manifest_body, build_proxy_session_id, - build_terminal_tail_plan, build_transient_resource_id, - commit_terminal_tail_if_lease_reserve_requires_cutover, finish_hls_origin_account_io, - prepare_terminal_base_evidence, prepared_terminal_bundle_key, snapshot_terminal_media_asset, - trigger_origin_refresh_sync, ActiveProviderManager, ActiveUserManager, AppState, CacheAccessState, - CancelTokens, ConnectionKind, ConnectionManager, CreateUserSessionParams, CustomVideoStreamType, - EventManager, HlsAcceptanceEpisodeTiming, HlsAcceptanceEpisodeTimingInput, HlsAccessAdmissionMode, - HlsAccessContext, HlsAccessLease, HlsAccessLeaseId, HlsAccessLeaseState, HlsAccessLeaseTiming, - HlsAccessLeaseTouch, HlsAccessLeaseValidationError, HlsAvailabilityReevaluationFinishReason, - HlsAvailabilityReevaluationMode, HlsAvailabilityReevaluationRegistration, HlsBandwidthPersistenceState, - HlsEffectiveOriginAcquirePolicy, HlsFreshManifestRequiredReason, HlsLeaseManifestSegment, - HlsLeaseManifestSnapshot, HlsLeasePlaybackMode, HlsLifecycleEvent, HlsLifecycleEventKey, - HlsManifestAcceptanceDirective, HlsManifestAcceptanceEvaluationOutcome, - HlsManifestAcceptanceExhaustionReason, HlsManifestAcceptanceTrigger, HlsManifestCommitIdentity, - HlsManifestCommitRequirement, HlsManifestDeliveryMode, HlsMapSignature, HlsMediaContainer, - HlsObservedRecoveryLatency, HlsOperationTimeoutMs, HlsOriginAccountBinding, HlsOriginAccountBindingMode, - HlsOriginAccountDetachedReason, HlsOriginIoContext, HlsOriginPathCondition, HlsOriginSource, - HlsOriginSourceKind, HlsPlaybackFamilyKey, HlsPreparedTerminalBundleState, HlsProxyManager, - HlsPublishedTransientResourceIds, HlsRecoveryEtaMs, HlsRecoveryTimingPolicy, HlsRecoveryWorkload, - HlsRuntimeCustomTailAssetIdentity, HlsRuntimeCustomTailReason, HlsSegmentFile, HlsSession, - HlsSessionHandle, HlsSessionKey, HlsSessionMode, HlsSessionStoreOutcome, HlsTerminalAssetIdentity, - HlsTerminalBaseMediaState, HlsTerminalBaseProtection, HlsTerminalBaseSegmentAvailability, - HlsTerminalFailedClosedReason, HlsTerminalMediaAsset, HlsTerminalMediaPreparationState, - HlsTerminalResolution, HlsTerminalSegmentPath, HlsTerminalTailBuildInput, HlsTerminalTailCompatibility, - HlsTerminalTailGeneration, HlsTerminalTailPlan, HlsTerminalTailProtection, HlsTransitionMarginMs, - LiveHlsOriginEntry, ManualPlaylistUpdateRequest, MapCacheStatus, MapEntry, MetadataUpdateManager, - OriginMapKey, OriginRefreshRequest, OriginSegmentFetchRef, OriginSegmentKey, PlaybackLifecycle, - PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, ProviderConfigConnection, ProxyMapId, - ProxySessionId, RenderedManifest, RetryPolicy, SegmentCacheKey, SegmentCacheStatus, SegmentEntry, - SegmentFetchPriority, SharedStreamManager, TransientObjectCacheKey, TransientObjectCacheStatus, - TransientResourceId, TransientResourceKind, TransientResourceRef, TransportStreamBuffer, UpdateGuard, - UserSession, HLS_TERMINAL_TAIL_SEGMENT_COUNT, - }, - auth::Fingerprint, - model::{ - ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, - ConfigTarget, CustomStreamResponse, HlsCacheConfig, ProcessTargets, ProxyUserCredentials, - ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig, TargetUser, - }, - processing::parser::hls::{ - origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome}, - rewrite_hls, RewriteHlsProps, - }, - repository::GeoIp, - }; - use aes::{ - cipher::{Block, BlockEncrypt, KeyInit}, - Aes128, - }; - use arc_swap::{ArcSwap, ArcSwapOption}; - use axum::{ - body::Body, - extract::ConnectInfo, - http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode}, - response::IntoResponse, - }; - use http_body_util::BodyExt; - use shared::{ - model::{ - provider_saturation::build_group_lookup, ConfigPaths, ConfigProviderDto, ConfigTargetDto, - ConfigTargetOptions, ConfigTargetShareLiveStreams, HlsCacheConfigDto, HlsManifestRecoveryBurstConfigDto, - HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripConfigDto, HlsStripMode, InputType, - M3uPlaylistItem, M3uTargetOutputDto, PlaylistItem, PlaylistItemHeader, PlaylistItemType, - ProviderUrlSelectionPolicy, ReverseProxyConfigDto, StreamConfigDto, StreamProperties, TargetOutputDto, - UserConnectionPermission, VirtualId, XtreamCluster, XtreamTargetOutputDto, - }, - utils::Internable, - }; - use std::{ - collections::HashMap, - fmt::Write as _, - net::SocketAddr, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, - time::Duration, - }; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - sync::{mpsc, RwLock}, - }; - use tower::ServiceExt; - - fn path_has_extension(path: &str, extension: &str) -> bool { - std::path::Path::new(path).extension().is_some_and(|actual| actual.eq_ignore_ascii_case(extension)) - } - - #[test] - fn archive_epg_reference_supports_query_and_path_formats() { - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?utc=1700000000&lutc=1700003600"), - Some(1_700_000_000) - ); - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/archive-1700003600-1700007200.m3u8"), - Some(1_700_003_600) - ); - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/timeshift_abs-1700007200.ts"), - Some(1_700_007_200) - ); - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?start=1700000000&end=1700003600"), - Some(1_700_000_000) - ); - } - - #[test] - 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 date_tree_path_recovers_bittv_archive_epg_reference() { - assert_eq!(super::epg_reference_ts_from_date_tree_path("2026/07/24/14/13/38-06800.ts"), Some(1_784_902_418)); - assert_eq!( - super::epg_reference_ts_from_date_tree_path("dvr-2026/07/24/14/13/38-06800.ts"), - super::epg_reference_ts_from_date_tree_path("2026/07/24/14/13/38-06800.ts") - ); - assert!(super::looks_like_archive_media_path("2026/07/24/14/13/38-06800.ts")); - } - - #[test] - fn archive_media_path_does_not_accept_plain_202_prefixed_segments() { - assert!(!super::looks_like_archive_media_path("2026.ts")); - assert!(!super::looks_like_archive_media_path("202_media.ts")); - } - - #[test] - fn session_token_recovers_archive_epg_reference_when_media_url_lost_markers() { - assert_eq!( - m3u_catchup_epg_reference_from_session_token("m3u-catchup|user|42|archive|1717200000|3600"), - Some(1_717_200_000) - ); - assert_eq!(m3u_catchup_epg_reference_from_session_token("m3u-catchup|user|42|live"), None); - } - - #[test] - fn append_catchup_session_hint_keeps_m3u_catchup_token_without_shared_hls_cache() { - let fingerprint = test_fingerprint(); - let hint = "m3u-catchup|fp|alice|42|deadbeef"; - let token = super::hls_entry_user_session_token(&fingerprint, "alice", 42, Some(hint), Some(1_717_200_000)); - assert_eq!(token, hint); - assert!(super::is_m3u_catchup_session_token(&token)); - - let from_archive = super::hls_entry_user_session_token(&fingerprint, "alice", 42, None, Some(1_717_200_000)); - assert!(from_archive.contains("|archive|1717200000|0")); - assert!(super::is_m3u_catchup_session_token(&from_archive)); - assert!(super::is_m3u_catchup_session_token("m3u-catchup|fp|alice|42|timeshift_abs|1717200000|0")); - } - - #[test] - fn leaked_dvr_relative_joins_against_media_playlist_and_dvr_session_root() { - assert_eq!( - resolve_leaked_hls_relative_origin( - "http://cdn.example/big/aa_1/media.m3u8", - "dvr-2026/07/26/15/30/59-06000.ts", - Some("token=abc"), - ), - Some("http://cdn.example/big/aa_1/dvr-2026/07/26/15/30/59-06000.ts?token=abc".to_string()) - ); - assert_eq!( - resolve_leaked_hls_relative_origin( - "http://cdn.example/big/aa_1/dvr-2026/07/26/15/30/59-06000.ts?token=old", - "dvr-2026/07/26/15/31/05-06000.ts", - Some("token=new"), - ), - Some("http://cdn.example/big/aa_1/dvr-2026/07/26/15/31/05-06000.ts?token=new".to_string()) - ); - assert_eq!( - resolve_leaked_hls_relative_origin("http://cdn.example/big/aa_1/media.m3u8", "segment001.ts", None,), - None - ); - } - - #[test] - fn archive_epg_reference_supports_contextual_start_aliases() { - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?offset=-3600&utcstart=1717200000"), - Some(1_717_200_000) - ); - assert_eq!( - m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?timestamp=1717200000&offset=120"), - Some(1_717_200_000) - ); - } - - #[test] - fn canonical_manifest_joins_authoritative_owner_and_fails_closed_without_one() { - assert_eq!( - hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::Scheduled), - HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::Scheduled) - ); - assert_eq!( - hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::AlreadyOwned), - HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) - ); - assert_eq!( - hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::Superseded), - HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) - ); - for (registration, failure) in [ - ( - HlsAvailabilityReevaluationRegistration::CapacityExceeded, - HlsCanonicalOwnerRegistrationFailure::CapacityExceeded, - ), - ( - HlsAvailabilityReevaluationRegistration::RuntimeUnavailable, - HlsCanonicalOwnerRegistrationFailure::RuntimeUnavailable, - ), - ] { - assert_eq!( - hls_canonical_owner_registration(registration), - HlsCanonicalOwnerRegistration::FailClosed(failure) - ); - let response = hls_availability_reevaluation_registration_failure_response(failure); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert!(response.headers().contains_key(header::RETRY_AFTER)); - } - } - - struct CanonicalOwnerHandoffFixture { - app_state: Arc, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - leases: Vec<(HlsAccessLeaseId, u64)>, - strip: StripConfig, - } - - impl CanonicalOwnerHandoffFixture { - async fn new(lease_ids: &[&str]) -> Self { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "owner-handoff"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let issued_at_ms = super::current_time_millis(); - let mut leases = Vec::with_capacity(lease_ids.len()); - for lease_id in lease_ids { - let lease_id = HlsAccessLeaseId((*lease_id).to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - format!("{}-session", lease_id.0), - 1, - "owner-handoff".to_string(), - 12345, - issued_at_ms, - 60_000, - )) - .await; - leases.push((lease_id, issued_at_ms)); - } - Self { - app_state, - session, - proxy_session_id, - leases, - strip: StripConfig { mode: HlsStripMode::Segments, value: 0 }, - } - } - - async fn safe_session(&self) -> String { - let session = self.session.read().await; - super::safe_session_key(&session.key) - } - - fn handoff_context( - &self, - lease_index: usize, - safe_session: String, - request_deadline_ms: u64, - ) -> super::HlsCanonicalOwnerHandoffContext<'_> { - let (lease_id, issued_at_ms) = &self.leases[lease_index]; - super::HlsCanonicalOwnerHandoffContext { - app_state: &self.app_state, - proxy_session_id: &self.proxy_session_id, - access_lease_id: lease_id, - expected_lease_issued_at_ms: Some(*issued_at_ms), - strip: &self.strip, - server_path: None, - manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, - manifest_boundary_rendered_at_ms: 0, - bandwidth_learning: super::HlsRuntimeBandwidthLearningContext::Disabled, - request_deadline_ms, - safe_session, - } - } - } - - async fn publish_owner_handoff_test_manifest(session: &HlsSessionHandle) { - let now_ms = super::current_time_millis(); - let manifest = normal_manifest( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ - #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", - ); - let mut session = session.write().await; - session.apply_origin_manifest(&manifest).expect("owner handoff manifest maps"); - for segment in session.segments.values_mut() { - segment.status = SegmentCacheStatus::Ready { content_length: 1_000, ready_at_ms: now_ms }; - } - session.advance_media_readiness_generation(); - session.render_and_store_manifest(now_ms).expect("owner handoff manifest renders"); - session.mark_authorized_media_access(now_ms); - } - - #[tokio::test] - async fn scheduled_owner_does_not_return_transient_503() { - let fixture = CanonicalOwnerHandoffFixture::new(&["scheduled-lease"]).await; - let owner_key = fixture - .app_state - .hls_proxy - .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) - .await - .expect("owner key"); - let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); - let started = Arc::new(tokio::sync::Notify::new()); - let release = Arc::new(tokio::sync::Notify::new()); - let completed = Arc::new(tokio::sync::Notify::new()); - let task_started = Arc::clone(&started); - let task_release = Arc::clone(&release); - let task_completed = Arc::clone(&completed); - let task_session = Arc::clone(&fixture.session); - let task_app_state = Arc::clone(&fixture.app_state); - let task_proxy_session_id = fixture.proxy_session_id.clone(); - let task_owner_key = owner_key.clone(); - assert_eq!( - coordinator.register( - owner_key, - HlsAvailabilityReevaluationMode::RecoveryPressure, - move |ownership| async move { - task_started.notify_one(); - task_release.notified().await; - publish_owner_handoff_test_manifest(&task_session).await; - task_app_state.hls_proxy.notify_session_evidence_changed(&task_proxy_session_id); - let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); - task_completed.notify_one(); - }, - ), - HlsAvailabilityReevaluationRegistration::Scheduled - ); - started.notified().await; - let deadline_ms = super::current_time_millis().saturating_add(60_000); - let safe_session = fixture.safe_session().await; - let mut response = Box::pin(super::join_hls_canonical_manifest_owner( - fixture.handoff_context(0, safe_session, deadline_ms), - HlsCanonicalOwnerRegistrationKind::Scheduled, - )); - - assert!(matches!(futures::poll!(response.as_mut()), std::task::Poll::Pending)); - release.notify_one(); - - let response = response.await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::RETRY_AFTER)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("live manifest utf8"); - assert!(body.contains("/scheduled-lease/")); - completed.notified().await; - } - - #[tokio::test] - async fn already_owned_work_is_joined_without_duplicate_refresh() { - let fixture = CanonicalOwnerHandoffFixture::new(&["first-lease", "second-lease"]).await; - let owner_key = fixture - .app_state - .hls_proxy - .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) - .await - .expect("owner key"); - let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); - let release = Arc::new(tokio::sync::Notify::new()); - let completed = Arc::new(tokio::sync::Notify::new()); - let task_release = Arc::clone(&release); - let task_completed = Arc::clone(&completed); - let task_session = Arc::clone(&fixture.session); - let task_app_state = Arc::clone(&fixture.app_state); - let task_proxy_session_id = fixture.proxy_session_id.clone(); - let task_owner_key = owner_key.clone(); - assert_eq!( - coordinator.register( - owner_key.clone(), - HlsAvailabilityReevaluationMode::RecoveryPressure, - move |ownership| async move { - task_release.notified().await; - publish_owner_handoff_test_manifest(&task_session).await; - task_app_state.hls_proxy.notify_session_evidence_changed(&task_proxy_session_id); - let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); - task_completed.notify_one(); - }, - ), - HlsAvailabilityReevaluationRegistration::Scheduled - ); - let duplicate_owner_runs = Arc::new(AtomicUsize::new(0)); - let task_duplicate_owner_runs = Arc::clone(&duplicate_owner_runs); - assert_eq!( - coordinator.register(owner_key, HlsAvailabilityReevaluationMode::RecoveryPressure, move |_| async move { - task_duplicate_owner_runs.fetch_add(1, Ordering::SeqCst); - },), - HlsAvailabilityReevaluationRegistration::AlreadyOwned - ); - let deadline_ms = super::current_time_millis().saturating_add(60_000); - let mut first = Box::pin(super::join_hls_canonical_manifest_owner( - fixture.handoff_context(0, fixture.safe_session().await, deadline_ms), - HlsCanonicalOwnerRegistrationKind::AlreadyOwned, - )); - let mut second = Box::pin(super::join_hls_canonical_manifest_owner( - fixture.handoff_context(1, fixture.safe_session().await, deadline_ms), - HlsCanonicalOwnerRegistrationKind::AlreadyOwned, - )); - assert!(matches!(futures::poll!(first.as_mut()), std::task::Poll::Pending)); - assert!(matches!(futures::poll!(second.as_mut()), std::task::Poll::Pending)); - - release.notify_one(); - let (first, second) = tokio::join!(first, second); - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::OK); - let first_body = String::from_utf8(response_body(first).await.to_vec()).expect("first manifest utf8"); - let second_body = String::from_utf8(response_body(second).await.to_vec()).expect("second manifest utf8"); - assert!(first_body.contains("/first-lease/")); - assert!(!first_body.contains("/second-lease/")); - assert!(second_body.contains("/second-lease/")); - assert!(!second_body.contains("/first-lease/")); - assert_eq!(duplicate_owner_runs.load(Ordering::SeqCst), 0); - completed.notified().await; - tokio::task::yield_now().await; - assert_eq!(coordinator.owner_count(), 0); - } - - #[tokio::test] - async fn canonical_owner_join_preserves_bounded_deadline_failure() { - let fixture = CanonicalOwnerHandoffFixture::new(&["deadline-lease"]).await; - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot( - &fixture.leases[0].0, - &fixture.proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("pending deadline lease"); - assert_eq!( - super::hls_canonical_owner_request_deadline_ms(&lease, Duration::ZERO, super::current_time_millis(),), - lease.pending_deadline_ms().expect("pending lease deadline") - ); - let owner_key = fixture - .app_state - .hls_proxy - .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) - .await - .expect("owner key"); - let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); - assert_eq!( - coordinator.register( - owner_key, - HlsAvailabilityReevaluationMode::RecoveryPressure, - |ownership| async move { - ownership.cancelled().await; - }, - ), - HlsAvailabilityReevaluationRegistration::Scheduled - ); - let response = super::join_hls_canonical_manifest_owner( - fixture.handoff_context(0, fixture.safe_session().await, super::current_time_millis().saturating_sub(1)), - HlsCanonicalOwnerRegistrationKind::Scheduled, - ) - .await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert!(!response.headers().contains_key(header::RETRY_AFTER)); - coordinator.cancel_session(&fixture.proxy_session_id); - } - - async fn terminal_generation_for_lease( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - ) -> HlsTerminalTailGeneration { - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) - .await - .expect("terminal lease remains stored"); - let HlsLeasePlaybackMode::TerminalTail(plan) = lease.playback_mode else { - panic!("lease remains terminal"); - }; - plan.generation - } - - #[tokio::test] - async fn completed_owner_resolves_new_lease_standalone_without_reusing_old_terminal() { - let temp_dir = tempfile::tempdir().expect("owner handoff cache tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - enable_channel_unavailable_custom_response(&app_state); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"old-terminal-base").await; - let old_lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &old_lease_id.0, 123).await; - let proxy_session_id = ProxySessionId(proxy_session_id); - let now_ms = super::current_time_millis(); - let old_generation = terminal_generation_for_lease(&app_state, &proxy_session_id, &old_lease_id).await; - let new_lease_id = HlsAccessLeaseId("new-owner-handoff-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - new_lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - "new-owner-handoff-session".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - 60_000, - )) - .await; - let session = - app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.expect("shared session"); - let owner_key = app_state - .hls_proxy - .availability_reevaluation_owner_key(&session, &proxy_session_id) - .await - .expect("owner key"); - let release = Arc::new(tokio::sync::Notify::new()); - let task_release = Arc::clone(&release); - let task_owner_key = owner_key.clone(); - let coordinator = app_state.hls_proxy.availability_reevaluations(); - assert_eq!( - coordinator.register( - owner_key, - HlsAvailabilityReevaluationMode::RecoveryPressure, - move |ownership| async move { - task_release.notified().await; - let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); - }, - ), - HlsAvailabilityReevaluationRegistration::Scheduled - ); - let safe_session = { - let session = session.read().await; - super::safe_session_key(&session.key) - }; - let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; - let mut response = Box::pin(super::join_hls_canonical_manifest_owner( - super::HlsCanonicalOwnerHandoffContext { - app_state: &app_state, - proxy_session_id: &proxy_session_id, - access_lease_id: &new_lease_id, - expected_lease_issued_at_ms: Some(now_ms), - strip: &strip, - server_path: None, - manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, - manifest_boundary_rendered_at_ms: 0, - bandwidth_learning: super::HlsRuntimeBandwidthLearningContext::Disabled, - request_deadline_ms: now_ms.saturating_add(60_000), - safe_session, - }, - HlsCanonicalOwnerRegistrationKind::Scheduled, - )); - assert!(matches!(futures::poll!(response.as_mut()), std::task::Poll::Pending)); - - release.notify_one(); - let response = response.await; - - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - assert!(!response.headers().contains_key(header::RETRY_AFTER)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone manifest utf8"); - assert!(body.contains("#EXT-X-ENDLIST")); - assert!(!body.contains("/hls/shared/live/")); - assert_eq!( - terminal_generation_for_lease(&app_state, &proxy_session_id, &old_lease_id).await, - old_generation, - "new lease fallback cannot reactivate old terminal lease" - ); - } - - #[test] - fn hls_cache_session_tokens_separate_live_and_archive_playback() { - let fingerprint = test_fingerprint(); - let live = super::create_hls_cache_user_session_token(&fingerprint, "user", 31, None, None); - let archive = super::create_hls_cache_user_session_token(&fingerprint, "user", 31, None, Some(1_784_898_000)); - - assert!(!super::is_m3u_catchup_session_token(&live)); - assert!(super::is_m3u_catchup_session_token(&archive)); - assert_ne!(live, archive); - } - - #[test] - fn hls_cache_session_token_preserves_existing_m3u_catchup_identity() { - let fingerprint = test_fingerprint(); - let existing = "m3u-catchup|fp|user|31|archive|1784898000|3600"; - let token = - super::create_hls_cache_user_session_token(&fingerprint, "user", 31, Some(existing), Some(1_784_898_000)); - - assert!(token.starts_with(existing)); - assert!(token.contains("|hls-cache|")); - } - - #[test] - fn hls_terminal_commit_endpoint_resolution_mapping_is_exhaustive() { - assert_eq!( - hls_terminal_endpoint_action(HlsTerminalResolution::LiveAllowed), - HlsTerminalEndpointAction::ServeLive - ); - assert_eq!( - hls_terminal_endpoint_action(HlsTerminalResolution::Committed), - HlsTerminalEndpointAction::ReloadTerminal - ); - assert_eq!( - hls_terminal_endpoint_action(HlsTerminalResolution::Reevaluate), - HlsTerminalEndpointAction::Reevaluate - ); - assert_eq!( - hls_terminal_endpoint_action(HlsTerminalResolution::Pending { retry_after_ms: 250 }), - HlsTerminalEndpointAction::RetryAfter { retry_after_ms: 250 } - ); - - for reason in [ - HlsTerminalFailedClosedReason::LeaseStateUnavailable, - HlsTerminalFailedClosedReason::BundleNotReadyWithoutOwner, - HlsTerminalFailedClosedReason::BundleIncompatible, - HlsTerminalFailedClosedReason::SafeCommitDeadlineElapsed, - HlsTerminalFailedClosedReason::RetryCapacityExceeded, - HlsTerminalFailedClosedReason::RetryAttemptsExhausted, - HlsTerminalFailedClosedReason::RuntimeUnavailable, - ] { - assert_eq!( - hls_terminal_endpoint_action(HlsTerminalResolution::FailedClosed { reason }), - HlsTerminalEndpointAction::FailClosed { reason } - ); - } - } - - #[tokio::test] - async fn hls_manifest_terminal_preflight_distinguishes_bootstrap_refresh_and_invalid_missing_snapshot() { - let app_state = test_app_state(); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "preflight-stream"), &app_state.get_encrypt_secret(), 1_000) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let mut lease = HlsAccessLease::pending( - HlsAccessLeaseId("preflight-lease".to_string()), - HlsPlaybackFamilyKey::new("hls-user", "preflight-client"), - proxy_session_id, - "hls-user".to_string(), - "preflight-user-session".to_string(), - 1, - "preflight-stream".to_string(), - 12345, - 1_000, - 120_000, - ); - - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 2_000).await, - HlsManifestTerminalPreflight::BootstrapPendingLease - ); - - lease.state = HlsAccessLeaseState::Activated; - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 2_000).await, - HlsManifestTerminalPreflight::FailClosed { reason: HlsTerminalFailedClosedReason::LeaseStateUnavailable } - ); - - lease.last_manifest_snapshot = Some(HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, - source_commit_identity: HlsManifestCommitIdentity::new(1_500), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 1, - delivered_at_ms: 1_500, - first_proxy_seq: 0, - last_proxy_seq: 0, - visible_segments: Arc::from([]), - discontinuity_sequence: 0, - target_duration_ms: 4_000, - playlist_duration_ms: 0, - last_visible_media_end_ms: 0, - active_map: None, - active_encryption: None, - container: HlsMediaContainer::MpegTs, - }); - { - let mut session = session.write().await; - session.origin_control.target_duration_snapshot_ms = Some(4_000); - session.origin_control.last_media_progress_at_ms = Some(2_000); - } - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 8_000).await, - HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation - ); - session.write().await.origin_control.last_media_progress_at_ms = Some(7_999); - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 8_000).await, - HlsManifestTerminalPreflight::EvaluateTerminal - ); - - lease.playback_mode = HlsLeasePlaybackMode::Ended; - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 8_000).await, - HlsManifestTerminalPreflight::ServeCommittedPlayback - ); - } - - #[tokio::test] - async fn hls_manifest_terminal_preflight_keeps_capacity_recovery_out_of_sync_terminal_wait() { - let app_state = test_app_state(); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "capacity-preflight"), &app_state.get_encrypt_secret(), 1_000) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - { - let mut session = session.write().await; - let OriginManifestParseOutcome::Normal(manifest) = parse_origin_media_manifest( - "#EXTM3U\n#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:0\n\ - #EXTINF:4.0,\n0.ts\n#EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n", - "http://origin.example/live/index.m3u8", - ) else { - panic!("capacity preflight manifest parses"); - }; - session.apply_origin_manifest(&manifest).expect("capacity preflight timeline applies"); - for segment in session.segments.values_mut() { - segment.status = SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: 1_000 }; - } - session.segments.get_mut(&1).expect("deferred segment").status = SegmentCacheStatus::CapacityDeferred { - priority: SegmentFetchPriority::Prefetch, - deferred_at_ms: 2_000, - }; - session.origin_control.target_duration_snapshot_ms = Some(8_000); - session.origin_control.last_media_progress_at_ms = Some(1_000); - } - let mut lease = HlsAccessLease::pending( - HlsAccessLeaseId("capacity-preflight-lease".to_string()), - HlsPlaybackFamilyKey::new("capacity-user", "capacity-client"), - proxy_session_id, - "capacity-user".to_string(), - "capacity-user-session".to_string(), - 1, - "capacity-preflight".to_string(), - 1, - 1_000, - 60_000, - ); - lease.state = HlsAccessLeaseState::Activated; - lease.last_manifest_snapshot = Some(HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, - source_commit_identity: HlsManifestCommitIdentity::new(1), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 1, - delivered_at_ms: 1_000, - first_proxy_seq: 0, - last_proxy_seq: 0, - visible_segments: Arc::from([HlsLeaseManifestSegment { - proxy_seq: 0, - duration_ms: 4_000, - uri: "000000.ts".to_string().into(), - discontinuity_before: false, - map_ref_ready: true, - encryption: None, - }]), - discontinuity_sequence: 0, - target_duration_ms: 8_000, - playlist_duration_ms: 4_000, - last_visible_media_end_ms: 4_000, - active_map: None, - active_encryption: None, - container: HlsMediaContainer::MpegTs, - }); - - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 20_000).await, - HlsManifestTerminalPreflight::EvaluateTerminal, - ); - - session.write().await.segments.get_mut(&1).expect("recovered segment").status = - SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: 20_001 }; - assert_eq!( - hls_manifest_terminal_preflight(&session, &lease, 20_001).await, - HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation, - ); - } - - #[test] - fn hls_terminal_commit_endpoint_pending_and_failed_closed_headers_are_distinct() { - let pending = hls_temporary_resource_unavailable_response(250); - assert_eq!(pending.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(pending.headers().get(header::RETRY_AFTER).and_then(|value| value.to_str().ok()), Some("1")); - assert!(pending.headers().get(header::LOCATION).is_none()); - - let failed_closed = hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::RetryAttemptsExhausted); - assert_eq!(failed_closed.status(), StatusCode::SERVICE_UNAVAILABLE); - assert!(failed_closed.headers().get(header::RETRY_AFTER).is_none()); - assert!(failed_closed.headers().get(header::LOCATION).is_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")); - } - - fn test_app_config() -> Arc { - let mut hls_user = ProxyUserCredentials::default(); - hls_user.username = "hls-user".to_string(); - hls_user.password = "hls-pass".to_string(); - hls_user.max_connections = 1; - let api_proxy = ApiProxyConfig { - server: vec![ApiProxyServerInfo { - name: "default".to_string(), - protocol: "https".to_string(), - host: "example.test".to_string(), - port: None, - timezone: "UTC".to_string(), - message: String::new(), - path: Some("iptv".to_string()), - }], - user: vec![TargetUser { target: "default".to_string(), credentials: vec![Arc::new(hls_user)] }], - ..Default::default() - }; - Arc::new(AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config { - custom_stream_response_enabled: true, - ..Default::default() - })), - sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), - hdhomerun: Arc::new(ArcSwapOption::empty()), - api_proxy: Arc::new(ArcSwapOption::from(Some(Arc::new(api_proxy)))), - file_locks: Arc::new(crate::utils::FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - })), - custom_stream_response: Arc::new(ArcSwapOption::empty()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), - }) - } - - fn hls_custom_video_test_user() -> ProxyUserCredentials { - let mut user = ProxyUserCredentials::default(); - user.username = "viewer".to_string(); - user.password = "secret".to_string(); - user - } - - fn test_hls_share_target(hls_enabled: bool) -> ConfigTarget { - ConfigTarget::from(&ConfigTargetDto { - id: 1, - name: "default".to_string(), - options: Some(ConfigTargetOptions { - share_live_streams: ConfigTargetShareLiveStreams { hls: hls_enabled, mpeg_ts: false }, - ..Default::default() - }), - output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto::default())], - ..Default::default() - }) - } - - fn test_m3u_hls_share_target() -> ConfigTarget { - ConfigTarget::from(&ConfigTargetDto { - id: 2, - name: "m3u-target".to_string(), - options: Some(ConfigTargetOptions { - share_live_streams: ConfigTargetShareLiveStreams { hls: true, mpeg_ts: false }, - ..Default::default() - }), - output: vec![TargetOutputDto::M3u(M3uTargetOutputDto::default())], - use_memory_cache: true, - ..Default::default() - }) - } - - fn test_m3u_hls_item(input: &ConfigInput, virtual_id: u32, input_stream_id: &str, url: &str) -> M3uPlaylistItem { - M3uPlaylistItem::from(&PlaylistItem { - header: PlaylistItemHeader { - id: input_stream_id.intern(), - virtual_id: VirtualId::new(virtual_id), - input_name: Arc::clone(&input.name), - url: url.intern(), - item_type: PlaylistItemType::LiveHls, - xtream_cluster: XtreamCluster::Live, - input_stream_id: input_stream_id.intern(), - ..PlaylistItemHeader::default() - }, - }) - } - - fn test_hls_entry_stream_context( - virtual_id: u32, - input_stream_id: &str, - known_bitrate_bps: Option, - ) -> super::HlsEntryStreamContext { - super::HlsEntryStreamContext { - identity: super::HlsEntryStreamIdentity::new(virtual_id, input_stream_id) - .expect("valid input stream identity"), - known_bitrate_bps, - } - } - - async fn cache_test_m3u_hls_item(app_state: &Arc, target: &ConfigTarget, item: M3uPlaylistItem) { - let mut playlist = crate::repository::BPlusTree::new(); - playlist.insert(item.virtual_id.get(), item); - app_state - .playlists - .cache_playlist(&target.name, crate::api::model::PlaylistStorage::M3uPlaylist(Box::new(playlist))) - .await; - } - - fn test_hls_input() -> ConfigInput { - ConfigInput { - id: 1, - name: Arc::from("test-input"), - input_type: InputType::Xtream, - url: "http://origin.example.com".to_string(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - } - } - - #[derive(Clone, Copy)] - enum TestLiveBitrateRepositoryState { - MissingDatabase, - MissingStreamItem, - ExistingHigher, - Update, - PermanentlyInapplicable, - RepositoryIoError, - } - - impl TestLiveBitrateRepositoryState { - const fn input_name(self) -> &'static str { - match self { - Self::MissingDatabase => "bandwidth-missing-database", - Self::MissingStreamItem => "bandwidth-missing-item", - Self::ExistingHigher => "bandwidth-existing-higher", - Self::Update => "bandwidth-update", - Self::PermanentlyInapplicable => "bandwidth-inapplicable", - Self::RepositoryIoError => "bandwidth-io-error", - } - } - } - - fn prepare_test_live_bitrate_repository( - input: &ConfigInput, - storage_root: &std::path::Path, - repository_state: TestLiveBitrateRepositoryState, - ) { - let storage_path = - crate::repository::build_input_storage_path(&input.name, storage_root.to_string_lossy().as_ref()); - let database_path = crate::repository::get_input_m3u_playlist_file_path(&storage_path, &input.name); - match repository_state { - TestLiveBitrateRepositoryState::MissingDatabase - | TestLiveBitrateRepositoryState::PermanentlyInapplicable => {} - TestLiveBitrateRepositoryState::RepositoryIoError => { - std::fs::create_dir_all(&storage_path).expect("input storage"); - std::fs::write(&database_path, b"invalid btree data").expect("corrupt input tree"); - } - TestLiveBitrateRepositoryState::MissingStreamItem - | TestLiveBitrateRepositoryState::ExistingHigher - | TestLiveBitrateRepositoryState::Update => { - std::fs::create_dir_all(&storage_path).expect("input storage"); - let (stream_ref, stored_bitrate) = match repository_state { - TestLiveBitrateRepositoryState::MissingStreamItem => ("different-channel", 0), - TestLiveBitrateRepositoryState::ExistingHigher => ("channel-a", 3_000_000), - TestLiveBitrateRepositoryState::Update => ("channel-a", 0), - TestLiveBitrateRepositoryState::MissingDatabase - | TestLiveBitrateRepositoryState::PermanentlyInapplicable - | TestLiveBitrateRepositoryState::RepositoryIoError => unreachable!(), - }; - let mut item = test_m3u_hls_item(input, 12345, stream_ref, "http://origin.test/live.m3u8"); - item.additional_properties = - Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - bitrate: stored_bitrate, - ..Default::default() - }))); - let mut tree = crate::repository::BPlusTree::new(); - tree.insert(Arc::clone(&item.provider_id), item); - tree.store(&database_path).expect("input tree"); - } - } - } - - async fn prepare_runtime_bandwidth_session( - app_state: &Arc, - input: &ConfigInput, - manifest_rendered_at_ms: u64, - ) -> HlsSessionHandle { - let origin_source = super::build_hls_origin_source(input, "channel-a"); - let (session, _) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - origin_source.session_key(), - origin_source, - &app_state.get_encrypt_secret(), - manifest_rendered_at_ms, - ) - .await; - let manifest = normal_manifest( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ - #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", - ); - { - let mut session_guard = session.write().await; - session_guard.apply_origin_manifest(&manifest).expect("runtime learning timeline"); - for entry in session_guard.segments.values_mut() { - entry.status = - SegmentCacheStatus::Ready { content_length: 1_000_000, ready_at_ms: manifest_rendered_at_ms }; - } - session_guard.advance_media_readiness_generation(); - session_guard.render_and_store_manifest(manifest_rendered_at_ms).expect("runtime learning manifest"); - session_guard.mark_authorized_media_access(manifest_rendered_at_ms); - } - session - } - - async fn hls_runtime_bandwidth_manifest_case( - repository_state: TestLiveBitrateRepositoryState, - ) -> (HlsBandwidthPersistenceState, Option) { - let temp = tempfile::tempdir().expect("temp dir"); - let app_state = test_app_state(); - let current_config = app_state.app_config.config.load(); - app_state.app_config.config.store(Arc::new(Config { - storage_dir: temp.path().to_string_lossy().into_owned(), - ..current_config.as_ref().clone() - })); - let input = ConfigInput { - id: 7, - name: Arc::from(repository_state.input_name()), - input_type: if matches!(repository_state, TestLiveBitrateRepositoryState::PermanentlyInapplicable) { - InputType::Library - } else { - InputType::M3u - }, - ..ConfigInput::default() - }; - prepare_test_live_bitrate_repository(&input, temp.path(), repository_state); - let manifest_rendered_at_ms = super::current_time_millis(); - let session = prepare_runtime_bandwidth_session(&app_state, &input, manifest_rendered_at_ms).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let access_lease_id = HlsAccessLeaseId(format!("{}-lease", repository_state.input_name())); - let now_ms = super::current_time_millis(); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - access_lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id, - "hls-user".to_string(), - "hls-session-token".to_string(), - input.id, - "channel-a".to_string(), - 12345, - now_ms, - 60_000, - )) - .await; - - let response = super::try_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 0 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - super::HlsRuntimeBandwidthLearningContext::Eligible(&input), - ) - .await - .expect("cached media manifest response"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response_body(response).await.is_empty()); - - let bandwidth_persistence = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let state = session.read().await.bandwidth_persistence; - if !matches!(state, HlsBandwidthPersistenceState::Idle | HlsBandwidthPersistenceState::InFlight { .. }) - { - break state; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("bandwidth persistence completion"); - let stored_bitrate = if matches!( - repository_state, - TestLiveBitrateRepositoryState::ExistingHigher | TestLiveBitrateRepositoryState::Update - ) { - crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") - .await - .expect("stored bitrate read") - } else { - None - }; - (bandwidth_persistence, stored_bitrate) - } - - #[tokio::test] - async fn hls_runtime_bandwidth_missing_database_retries_without_failing_media_manifest() { - let (state, _) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::MissingDatabase).await; - - assert!(matches!(state, HlsBandwidthPersistenceState::RetryAfter { .. })); - } - - #[tokio::test] - async fn hls_runtime_bandwidth_missing_item_retries_without_failing_media_manifest() { - let (state, _) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::MissingStreamItem).await; - - assert!(matches!(state, HlsBandwidthPersistenceState::RetryAfter { .. })); - } - - #[tokio::test] - async fn hls_runtime_bandwidth_existing_higher_completes_without_failing_media_manifest() { - let (state, stored_bitrate) = - hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::ExistingHigher).await; - - assert!(matches!(state, HlsBandwidthPersistenceState::Persisted { bitrate_bps: 2_000_000 })); - assert_eq!(stored_bitrate, Some(3_000_000)); - } - - #[tokio::test] - async fn hls_runtime_bandwidth_update_completes_without_failing_media_manifest() { - let (state, stored_bitrate) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::Update).await; - - assert!(matches!(state, HlsBandwidthPersistenceState::Persisted { bitrate_bps: 2_000_000 })); - assert_eq!(stored_bitrate, Some(2_000_000)); - } - - #[tokio::test] - async fn hls_runtime_bandwidth_inapplicable_and_io_error_do_not_fail_media_manifest() { - let (inapplicable, _) = - hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::PermanentlyInapplicable).await; - let (io_error, _) = - hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::RepositoryIoError).await; - - assert!(matches!( - inapplicable, - HlsBandwidthPersistenceState::PermanentlyInapplicable { bitrate_bps: 2_000_000 } - )); - assert!(matches!(io_error, HlsBandwidthPersistenceState::RetryAfter { .. })); - } - - #[tokio::test] - async fn hls_runtime_bandwidth_persistence_is_entry_gated_and_deduplicated() { - let temp = tempfile::tempdir().expect("temp dir"); - let app_state = test_app_state(); - let current_config = app_state.app_config.config.load(); - app_state.app_config.config.store(Arc::new(Config { - storage_dir: temp.path().to_string_lossy().into_owned(), - ..current_config.as_ref().clone() - })); - let input = ConfigInput { - id: 7, - name: Arc::from("runtime-bandwidth-input"), - input_type: InputType::M3u, - ..ConfigInput::default() - }; - let storage_path = - crate::repository::build_input_storage_path(&input.name, temp.path().to_string_lossy().as_ref()); - std::fs::create_dir_all(&storage_path).expect("input storage"); - let item = test_m3u_hls_item(&input, 12345, "channel-a", "http://origin.test/live.m3u8"); - let mut tree = crate::repository::BPlusTree::new(); - tree.insert(Arc::clone(&item.provider_id), item); - tree.store(&crate::repository::get_input_m3u_playlist_file_path(&storage_path, &input.name)) - .expect("input tree"); - - let origin_source = super::build_hls_origin_source(&input, "channel-a"); - let (session, _) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - origin_source.session_key(), - origin_source, - &app_state.get_encrypt_secret(), - 1, - ) - .await; - { - let manifest = normal_manifest( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ - #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", - ); - let mut session_guard = session.write().await; - session_guard.apply_origin_manifest(&manifest).expect("runtime learning timeline"); - for entry in session_guard.segments.values_mut() { - entry.status = SegmentCacheStatus::Ready { content_length: 1_000_000, ready_at_ms: 2 }; - } - } - - assert!(super::spawn_hls_runtime_bandwidth_persistence( - &app_state, - &session, - super::HlsRuntimeBandwidthLearningContext::Disabled, - ) - .is_none()); - assert_eq!( - crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") - .await - .expect("unknown bitrate read"), - None - ); - - let task = super::spawn_hls_runtime_bandwidth_persistence( - &app_state, - &session, - super::HlsRuntimeBandwidthLearningContext::Eligible(&input), - ) - .expect("runtime persistence task"); - task.await.expect("runtime persistence completion"); - - assert_eq!( - crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") - .await - .expect("persisted bitrate read"), - Some(2_000_000) - ); - assert!(super::spawn_hls_runtime_bandwidth_persistence( - &app_state, - &session, - super::HlsRuntimeBandwidthLearningContext::Eligible(&input), - ) - .is_none()); - } - - fn store_test_sources_with_target(app_state: &Arc, input: ConfigInput, target: ConfigTarget) { - let input = Arc::new(input); - let inputs = vec![Arc::clone(&input)]; - app_state.app_config.sources.store(Arc::new(SourcesConfig { - batch_files: vec![], - provider: vec![], - group_lookup: build_group_lookup(&inputs), - inputs, - sources: vec![ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![Arc::new(target)] }], - templates: None, - })); - } - - fn configure_default_test_server(app_state: &Arc) { - let users = app_state - .app_config - .api_proxy - .load_full() - .as_ref() - .map_or_else(Vec::new, |api_proxy| api_proxy.user.clone()); - app_state.app_config.api_proxy.store(Some(Arc::new(ApiProxyConfig { - server: vec![ApiProxyServerInfo { - name: "default".to_string(), - protocol: "http".to_string(), - host: "127.0.0.1".to_string(), - port: Some("8901".to_string()), - timezone: "UTC".to_string(), - message: String::new(), - path: None, - }], - user: users, - ..Default::default() - }))); - } - - #[test] - fn hls_custom_video_manifest_body_is_none_for_non_provisioning() { - let user = hls_custom_video_test_user(); - let manifest = build_hls_custom_video_manifest_body( - "https://example.test/iptv/", - &user, - CustomVideoStreamType::UserConnectionsExhausted, - ); - - assert!(manifest.is_none(), "non-provisioning custom video types have no static manifest body"); - } - - #[tokio::test] - async fn hls_initial_manifest_decision_wait_timeout_defaults_to_ninety_seconds() { - let app_state = test_app_state(); - assert_eq!(super::hls_initial_manifest_decision_wait_timeout(&app_state), Duration::from_secs(90)); - } - - #[tokio::test] - async fn hls_manifest_channel_unavailable_renders_inline_without_redirect() { - let app_state = test_app_state(); - enable_channel_unavailable_custom_response(&app_state); - - let response = super::hls_manifest_channel_unavailable_response_for_username(&app_state, "hls-user").await; - - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - assert!(body.contains("#EXT-X-ENDLIST")); - } - - #[tokio::test] - async fn hls_manifest_channel_unavailable_falls_back_to_not_found_when_custom_response_is_disabled() { - let app_state = test_app_state(); - disable_custom_stream_response(&app_state); - - let response = super::hls_manifest_channel_unavailable_response_for_username(&app_state, "hls-user").await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - const RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES: u64 = 20 * 1024 * 1024; - - struct RecoveryBeforeCutoverFixture { - _temp_dir: tempfile::TempDir, - origin: TestSegmentOrigin, - origin_phase: Arc, - app_state: Arc, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - lease_id: HlsAccessLeaseId, - refresh: OriginRefreshRequest, - } - - async fn assert_initial_recovery_window(fixture: &RecoveryBeforeCutoverFixture) { - let response = try_test_hls_cached_manifest_response( - &fixture.app_state, - &fixture.session, - &fixture.lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::initial(Duration::from_secs(10)), - ) - .await - .expect("READY initial manifest"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains("/000000.ts")); - assert!(body.contains("/000002.ts")); - assert!(!body.contains("/000003.ts")); - let now_ms = super::current_time_millis(); - assert!(fixture - .app_state - .hls_proxy - .activate_access_lease( - &fixture.lease_id, - &fixture.proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, - ) - .await - .is_activated()); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, now_ms) - .await - .expect("active stripped lease"); - let snapshot = lease.last_manifest_snapshot.as_ref().expect("lease manifest snapshot"); - assert_eq!(snapshot.visible_segments.len(), 3); - assert_eq!(snapshot.last_proxy_seq, 2); - let evidence = prepare_terminal_base_evidence( - &fixture.session, - fixture.app_state.hls_proxy.segment_cache(), - snapshot, - now_ms, - ) - .await; - assert_eq!(evidence.track_signature(), Some(terminal_test_asset().track_signature().clone())); - evidence.release(); - extend_ready_segment_as_sparse_file( - &fixture.app_state, - &fixture.session, - 2, - RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES, - ) - .await; - let uri = format!("/hls/shared/live/{}/{}/000002.ts", fixture.proxy_session_id.0, fixture.lease_id.0); - let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(segment.status(), StatusCode::OK); - assert_eq!(segment.headers()[header::CONTENT_LENGTH], RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES.to_string()); - assert_eq!( - u64::try_from(response_body(segment).await.len()).unwrap_or(u64::MAX), - RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES - ); - } - - async fn recovery_before_cutover_fixture() -> RecoveryBeforeCutoverFixture { - let temp_dir = tempfile::tempdir().expect("recovery cache tempdir"); - let unchanged_manifest = Arc::<[u8]>::from(regression_origin_manifest(100, 6)); - let progressed_manifest = Arc::<[u8]>::from(regression_origin_manifest(101, 6)); - let segment = Arc::<[u8]>::from( - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) - .as_slice(), - ); - let origin_phase = Arc::new(AtomicUsize::new(0)); - let phase = Arc::clone(&origin_phase); - let origin = spawn_test_binary_origin(Arc::new(move |path| { - if path_has_extension(path, "m3u8") { - return match phase.load(Ordering::SeqCst) { - 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&unchanged_manifest)), - 1 => TestBinaryOriginResponse::new( - StatusCode::PROXY_AUTHENTICATION_REQUIRED, - Arc::<[u8]>::from(&b"retry"[..]), - ), - _ => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&progressed_manifest)), - }; - } - TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&segment)) - })) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("recovery-regression-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = - test_app_state_with_hls_proxy_and_inputs(test_beast_hls_proxy(temp_dir.path()), vec![Arc::new(input)]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let manifest_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 1_000) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let lease_id = HlsAccessLeaseId("recovery-before-cutover".to_string()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; - let refresh = - regression_origin_refresh_request(&app_state, Arc::clone(&session), &manifest_url, Some(lease_id.clone())); - let fixture = RecoveryBeforeCutoverFixture { - _temp_dir: temp_dir, - origin, - origin_phase, - app_state, - session, - proxy_session_id, - lease_id, - refresh, - }; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - wait_for_ready_timeline(&fixture.session, 6).await; - assert_initial_recovery_window(&fixture).await; - fixture - } - - async fn run_recovery_outage(fixture: &mut RecoveryBeforeCutoverFixture) -> (u64, Option) { - let progress_generation = fixture.session.read().await.origin_control.progress_generation; - for _ in 0..2 { - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - } - assert_eq!(fixture.session.read().await.origin_control.progress_generation, progress_generation); - fixture.origin_phase.store(1, Ordering::SeqCst); - let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); - assert_eq!(fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(), plan); - fixture.refresh.acceptance_directive.trigger = HlsManifestAcceptanceTrigger::RecoveryRequired; - let requests_before = fixture.origin.manifest_request_count(); - let mut last_episode_generation = None; - for _ in 0..3 { - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot( - &fixture.lease_id, - &fixture.proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("407 exhaustion cannot remove the lease"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - let session = fixture.session.read().await; - let episode = session.origin_control.acceptance_episode.as_ref().expect("bounded acceptance evidence"); - assert!(episode.full_burst_completed); - assert_eq!(episode.completed_burst_candidates, plan.total_candidates()); - last_episode_generation = Some(episode.generation.0); - } - assert!( - fixture.origin.manifest_request_count().saturating_sub(requests_before) - >= plan.total_candidates().saturating_mul(3) - ); - let uri = format!("/hls/shared/live/{}/{}/000003.ts", fixture.proxy_session_id.0, fixture.lease_id.0); - let cached = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(cached.status(), StatusCode::OK); - assert!(!response_body(cached).await.is_empty()); - (progress_generation, last_episode_generation) - } - - async fn assert_recovery_after_outage( - fixture: &mut RecoveryBeforeCutoverFixture, - progress_generation: u64, - last_episode_generation: Option, - ) { - fixture.origin_phase.store(2, Ordering::SeqCst); - fixture.refresh.acceptance_directive.trigger = HlsManifestAcceptanceTrigger::RecoveryRequired; - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - wait_for_ready_timeline(&fixture.session, 7).await; - { - let session = fixture.session.read().await; - assert_eq!(session.origin_seq_highwater, Some(106)); - assert_eq!(session.proxy_next_seq, Some(7)); - assert!(session.origin_control.progress_generation > progress_generation); - assert!(session.origin_control.acceptance_episode.is_none()); - assert!(last_episode_generation - .is_some_and(|generation| session.origin_control.acceptance_generation.0 > generation)); - } - let response = try_test_hls_cached_manifest_response( - &fixture.app_state, - &fixture.session, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("recovered normal manifest"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("recovered manifest utf8"); - assert!(body.contains("/000006.ts")); - assert!(!body.contains("/terminal/")); - assert!(!body.contains("#EXT-X-ENDLIST")); - let uri = format!("/hls/shared/live/{}/{}/000006.ts", fixture.proxy_session_id.0, fixture.lease_id.0); - let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(segment.status(), StatusCode::OK); - assert!(!response_body(segment).await.is_empty()); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("recovered lease remains stored"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - } - - #[tokio::test] - async fn recovers_before_lease_cutover_without_terminal_tail() { - let mut fixture = recovery_before_cutover_fixture().await; - let (progress_generation, last_episode_generation) = run_recovery_outage(&mut fixture).await; - assert_recovery_after_outage(&mut fixture, progress_generation, last_episode_generation).await; - } - - struct StaleOriginServers { - pinned: TestSegmentOrigin, - alternative: TestSegmentOrigin, - pinned_phase: Arc, - alternative_phase: Arc, - burst_candidates: Arc, - pinned_candidates: Arc, - } - - async fn spawn_stale_origin_servers() -> StaleOriginServers { - let pinned_manifest = Arc::<[u8]>::from(regression_origin_manifest(100, 6)); - let alternative_manifest = Arc::<[u8]>::from(regression_origin_manifest(200, 6)); - let continued_manifest = Arc::<[u8]>::from(regression_origin_manifest(201, 6)); - let segment = Arc::<[u8]>::from( - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) - .as_slice(), - ); - let alternative_phase = Arc::new(AtomicUsize::new(0)); - let alternative_phase_for_handler = Arc::clone(&alternative_phase); - let alternative_segment = Arc::clone(&segment); - let alternative = spawn_test_binary_origin(Arc::new(move |path| { - if path_has_extension(path, "m3u8") { - let body = if alternative_phase_for_handler.load(Ordering::SeqCst) == 0 { - Arc::clone(&alternative_manifest) - } else { - Arc::clone(&continued_manifest) - }; - return TestBinaryOriginResponse::new(StatusCode::OK, body); - } - TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&alternative_segment)) - })) - .await; - let alternative_url = - format!("{}/live/user/pass/12345.m3u8", alternative.base_url.replacen("127.0.0.1", "localhost", 1)); - let pinned_phase = Arc::new(AtomicUsize::new(0)); - let burst_candidates = Arc::new(AtomicUsize::new(0)); - let pinned_candidates = Arc::new(AtomicUsize::new(0)); - let handler_phase = Arc::clone(&pinned_phase); - let handler_burst_candidates = Arc::clone(&burst_candidates); - let handler_pinned_candidates = Arc::clone(&pinned_candidates); - let handler_pinned_manifest = Arc::clone(&pinned_manifest); - let pinned_segment = Arc::clone(&segment); - let pinned = spawn_test_binary_origin(Arc::new(move |path| { - if !path_has_extension(path, "m3u8") { - return TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&pinned_segment)); - } - match handler_phase.load(Ordering::SeqCst) { - 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&handler_pinned_manifest)), - 1 => { - let index = handler_burst_candidates.fetch_add(1, Ordering::SeqCst); - if index.is_multiple_of(2) { - handler_pinned_candidates.fetch_add(1, Ordering::SeqCst); - TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&handler_pinned_manifest)) - } else { - TestBinaryOriginResponse::redirect(alternative_url.clone()) - } - } - _ => TestBinaryOriginResponse::redirect(alternative_url.clone()), - } - })) - .await; - StaleOriginServers { pinned, alternative, pinned_phase, alternative_phase, burst_candidates, pinned_candidates } - } - - struct StaleOriginFixture { - _temp_dir: tempfile::TempDir, - servers: StaleOriginServers, - app_state: Arc, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - lease_id: HlsAccessLeaseId, - refresh: OriginRefreshRequest, - initial_visible_tail: u64, - initial_progress_generation: u64, - initial_origin_epoch: u64, - initial_progress_at_ms: Option, - } - - async fn stale_origin_fixture() -> StaleOriginFixture { - let temp_dir = tempfile::tempdir().expect("stale-origin cache tempdir"); - let servers = spawn_stale_origin_servers().await; - let input = ConfigInput { - id: 1, - name: Arc::from("reachable-stale-origin-input"), - input_type: InputType::M3u, - url: servers.pinned.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = - test_app_state_with_hls_proxy_and_inputs(test_beast_hls_proxy(temp_dir.path()), vec![Arc::new(input)]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let manifest_url = format!("{}/live/user/pass/12345.m3u8", servers.pinned.base_url); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 1_000) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let lease_id = HlsAccessLeaseId("reachable-stale-origin".to_string()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; - let refresh = - regression_origin_refresh_request(&app_state, Arc::clone(&session), &manifest_url, Some(lease_id.clone())); - assert!(trigger_origin_refresh_sync(refresh.clone()).await); - wait_for_ready_timeline(&session, 6).await; - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::initial(Duration::from_secs(10)), - ) - .await - .expect("READY pinned-origin manifest"); - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); - assert_eq!(media_uri_count(&body), 3); - let now_ms = super::current_time_millis(); - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, - ) - .await - .is_activated()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&lease_id, &proxy_session_id, now_ms) - .await - .expect("activated pinned-origin lease"); - let initial_visible_tail = - lease.last_manifest_snapshot.as_ref().expect("initial lease manifest snapshot").last_proxy_seq; - let (initial_progress_generation, initial_origin_epoch, initial_progress_at_ms) = { - let session = session.read().await; - assert_eq!(session.origin_seq_highwater, Some(105)); - assert_eq!(session.last_effective_manifest_host.as_deref(), Some("127.0.0.1")); - ( - session.origin_control.progress_generation, - session.origin_epoch, - session.origin_control.last_media_progress_at_ms, - ) - }; - StaleOriginFixture { - _temp_dir: temp_dir, - servers, - app_state, - session, - proxy_session_id, - lease_id, - refresh, - initial_visible_tail, - initial_progress_generation, - initial_origin_epoch, - initial_progress_at_ms, - } - } - - async fn observe_stale_origin(fixture: &mut StaleOriginFixture) -> HlsManifestAcceptanceDirective { - let requests_before = fixture.servers.pinned.manifest_request_count(); - for _ in 0..2 { - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - } - { - let session = fixture.session.read().await; - assert_eq!(session.origin_seq_highwater, Some(105)); - assert_eq!(session.origin_control.progress_generation, fixture.initial_progress_generation); - assert_eq!(session.origin_control.last_media_progress_at_ms, fixture.initial_progress_at_ms); - assert_eq!(session.origin_refresh.consecutive_failures, 0); - } - assert_eq!(fixture.servers.pinned.manifest_request_count().saturating_sub(requests_before), 2); - assert_eq!(fixture.servers.alternative.manifest_request_count(), 0); - let response = try_test_hls_cached_manifest_response( - &fixture.app_state, - &fixture.session, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("reachable stale origin keeps the committed live manifest"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("stale live manifest utf8"); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); - assert!(!body.contains("/terminal/")); - assert!(!body.contains("#EXT-X-ENDLIST")); - { - let mut session = fixture.session.write().await; - for segment in - session.segments.values_mut().filter(|segment| segment.proxy_seq > fixture.initial_visible_tail) - { - segment.duration_ms = 10_000; - } - session.origin_control.last_media_progress_at_ms = Some(0); - session.advance_media_readiness_generation(); - } - let directive = match crate::api::model::hls_manifest_acceptance_directive_for_session( - &fixture.app_state.hls_ctx(), - &fixture.session, - &fixture.proxy_session_id, - ) - .await - { - HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => directive, - other => panic!("stale progress evidence must evaluate: {other:?}"), - }; - assert!(directive.trigger.recovery_required()); - assert_eq!(fixture.session.read().await.origin_control.path_condition, HlsOriginPathCondition::PublicationLate); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("stale progress evidence keeps the lease stored"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - directive - } - - async fn assert_stale_origin_handoff( - fixture: &mut StaleOriginFixture, - directive: HlsManifestAcceptanceDirective, - ) -> u64 { - let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); - assert_eq!(fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(), plan); - fixture.servers.pinned_phase.store(1, Ordering::SeqCst); - let requests_before = fixture.servers.pinned.manifest_request_count(); - fixture.refresh.acceptance_directive = directive; - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - wait_for_ready_timeline(&fixture.session, 12).await; - assert_eq!( - fixture.servers.pinned.manifest_request_count().saturating_sub(requests_before), - plan.total_candidates() - ); - assert_eq!(fixture.servers.burst_candidates.load(Ordering::SeqCst), plan.total_candidates()); - assert!(fixture.servers.pinned_candidates.load(Ordering::SeqCst) > 0); - assert!(fixture.servers.alternative.manifest_request_count() >= 2); - let progress_generation = { - let session = fixture.session.read().await; - assert_eq!(session.origin_seq_highwater, Some(205)); - assert_eq!(session.proxy_next_seq, Some(12)); - assert_eq!(session.last_effective_manifest_host.as_deref(), Some("localhost")); - assert_eq!(session.origin_epoch, fixture.initial_origin_epoch.saturating_add(1)); - assert!(session.origin_control.progress_generation > fixture.initial_progress_generation); - session.origin_control.progress_generation - }; - let response = try_test_hls_cached_manifest_response( - &fixture.app_state, - &fixture.session, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("cross-host recovery manifest"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("recovered manifest utf8"); - assert!(body.contains("/000006.ts")); - assert!(body.contains("#EXT-X-DISCONTINUITY")); - assert!(!body.contains("/terminal/")); - assert!(!body.contains("#EXT-X-ENDLIST")); - let uri = format!("/hls/shared/live/{}/{}/000006.ts", fixture.proxy_session_id.0, fixture.lease_id.0); - let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(segment.status(), StatusCode::OK); - assert!(!response_body(segment).await.is_empty()); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("recovered lease remains stored"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - progress_generation - } - - async fn assert_stale_origin_continuation(fixture: &mut StaleOriginFixture, progress_generation: u64) { - fixture.servers.alternative_phase.store(1, Ordering::SeqCst); - fixture.servers.pinned_phase.store(2, Ordering::SeqCst); - fixture.refresh.acceptance_directive = HlsManifestAcceptanceDirective::none(); - fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); - wait_for_ready_timeline(&fixture.session, 13).await; - { - let session = fixture.session.read().await; - assert_eq!(session.origin_seq_highwater, Some(206)); - assert_eq!(session.proxy_next_seq, Some(13)); - assert_eq!(session.origin_epoch, fixture.initial_origin_epoch.saturating_add(1)); - assert!(session.origin_control.progress_generation > progress_generation); - } - let response = try_test_hls_cached_manifest_response( - &fixture.app_state, - &fixture.session, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("continued alternative-origin timeline"); - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("continued manifest utf8"); - assert!(body.contains("/000012.ts")); - assert!(!body.contains("/terminal/")); - assert!(!body.contains("#EXT-X-ENDLIST")); - let uri = format!("/hls/shared/live/{}/{}/000012.ts", fixture.proxy_session_id.0, fixture.lease_id.0); - let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(segment.status(), StatusCode::OK); - assert!(!response_body(segment).await.is_empty()); - } - - #[tokio::test] - async fn reachable_stale_origin_hands_off_to_progressed_origin_without_terminal_tail() { - let mut fixture = stale_origin_fixture().await; - let directive = observe_stale_origin(&mut fixture).await; - let progress_generation = assert_stale_origin_handoff(&mut fixture, directive).await; - assert_stale_origin_continuation(&mut fixture, progress_generation).await; - } - - #[tokio::test] - async fn terminal_lease_manifest_is_inline_immutable_endlist_on_canonical_path() { - const LIVE_TAIL_BYTES: &[u8] = b"original-live-tail"; - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", LIVE_TAIL_BYTES).await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let cursor_before = app_state - .hls_proxy - .access_lease_response_snapshot( - &HlsAccessLeaseId(lease_id.clone()), - &ProxySessionId(proxy_session_id.clone()), - super::current_time_millis(), - ) - .await - .expect("terminal lease before media read") - .playback_cursor; - let (generation, segment_count) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; - let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/manifest.m3u8"); - let mut reloaded_api_proxy = - app_state.app_config.api_proxy.load_full().as_deref().cloned().expect("test API proxy config"); - reloaded_api_proxy.server[0].path = Some("reloaded".to_string()); - app_state.app_config.api_proxy.store(Some(Arc::new(reloaded_api_proxy))); - - let response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; - - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest is utf8"); - let repeated = get_response(Arc::clone(&app_state), &manifest_uri, None).await; - assert_eq!(repeated.status(), StatusCode::OK); - assert!(!repeated.headers().contains_key(header::LOCATION)); - assert_eq!(response_body(repeated).await, body.as_bytes()); - let live_tail = format!("/{proxy_session_id}/{lease_id}/000123.ts"); - let terminal_prefix = format!("/{proxy_session_id}/{lease_id}/terminal/{generation}/"); - assert!(body.contains(&live_tail)); - assert!(body.contains("/iptv/hls/shared/live/")); - assert!(!body.contains("/reloaded/hls/shared/live/")); - assert_eq!(body.matches(&terminal_prefix).count(), usize::from(segment_count)); - assert_eq!(body.matches("#EXT-X-DISCONTINUITY\n").count(), 1); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - assert!(body.find(&live_tail) < body.find("#EXT-X-DISCONTINUITY\n")); - assert!(body.find("#EXT-X-DISCONTINUITY\n") < body.find(&terminal_prefix)); - - let live_tail_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/000123.ts"); - let live_tail_response = get_response(Arc::clone(&app_state), &live_tail_uri, Some("bytes=0-")).await; - assert_eq!(live_tail_response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(live_tail_response.headers()[header::CONTENT_LENGTH], LIVE_TAIL_BYTES.len().to_string()); - assert_eq!( - live_tail_response.headers()[header::CONTENT_RANGE], - format!("bytes 0-{}/{}", LIVE_TAIL_BYTES.len() - 1, LIVE_TAIL_BYTES.len()) - ); - assert_eq!(response_body(live_tail_response).await, bytes::Bytes::from_static(LIVE_TAIL_BYTES)); - let cursor_after = app_state - .hls_proxy - .access_lease_response_snapshot( - &HlsAccessLeaseId(lease_id), - &ProxySessionId(proxy_session_id), - super::current_time_millis(), - ) - .await - .expect("terminal lease after media read") - .playback_cursor; - assert_eq!(cursor_after, cursor_before); - } - - struct PreparedTerminalCutoverFixture { - _temp_dir: tempfile::TempDir, - origin: TestSegmentOrigin, - app_state: Arc, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - lease_id: HlsAccessLeaseId, - request_url: String, - base_manifest: HlsLeaseManifestSnapshot, - asset_buffer: TransportStreamBuffer, - asset: Arc, - } - - async fn prepare_terminal_cutover_bundle( - app_state: &Arc, - base_manifest: &HlsLeaseManifestSnapshot, - ) -> (TransportStreamBuffer, Arc) { - let asset_buffer = app_state - .app_config - .custom_stream_response - .load_full() - .as_ref() - .and_then(|responses| responses.channel_unavailable.as_ref()) - .cloned() - .expect("configured terminal renderer"); - let asset = snapshot_terminal_media_asset(&asset_buffer).expect("terminal asset snapshot"); - let key = - prepared_terminal_bundle_key(&asset, base_manifest.target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); - let state = app_state.hls_proxy.start_prepared_terminal_bundle( - Arc::clone(&asset), - base_manifest.target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let state = match state { - HlsPreparedTerminalBundleState::Preparing { .. } => app_state - .hls_proxy - .wait_for_prepared_terminal_bundle(key) - .await - .expect("prepared terminal bundle completion"), - state => state, - }; - assert!(matches!( - state, - HlsPreparedTerminalBundleState::Ready { ref bundle } - if bundle.key == key - && bundle.segments.len() == usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT) - )); - assert_eq!(asset_buffer.finite_hls_render_count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); - (asset_buffer, asset) - } - - async fn prepared_terminal_cutover_fixture() -> PreparedTerminalCutoverFixture { - let temp_dir = tempfile::tempdir().expect("terminal cutover tempdir"); - let origin = spawn_test_encrypted_hls_origin( - AES_TEST_MANIFEST, - Arc::from(AES_TEST_KEY_BYTES), - Arc::from(AES_TEST_PLAINTEXT_SEGMENT), - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("terminal-regression-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_hls_proxy_and_inputs( - test_beast_hls_proxy(temp_dir.path()), - vec![Arc::new(input.clone())], - ); - enable_hls_cache(&app_state); - enable_channel_unavailable_custom_response(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/channel/index.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let lease_id = HlsAccessLeaseId("prepared-terminal-cutover".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("AES shared session cold start"); - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("AES manifest utf8"); - assert!(body.contains("#EXT-X-KEY:METHOD=AES-128")); - let session = app_state.hls_proxy.sessions().get_by_key(&session_key).await.expect("AES shared session"); - wait_for_ready_timeline(&session, 6).await; - let now_ms = super::current_time_millis(); - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, - ) - .await - .is_activated()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&lease_id, &proxy_session_id, now_ms) - .await - .expect("live AES lease"); - let base_manifest = lease.last_manifest_snapshot.as_ref().expect("frozen AES lease manifest").clone(); - assert_eq!(base_manifest.target_duration_ms, 12_000); - assert_eq!(base_manifest.visible_segments.len(), 3); - assert!(base_manifest.active_encryption.is_some()); - let (asset_buffer, asset) = prepare_terminal_cutover_bundle(&app_state, &base_manifest).await; - for segment in base_manifest.visible_segments.iter() { - let response = get_response(Arc::clone(&app_state), &segment.uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response_body(response).await.is_empty()); - } - PreparedTerminalCutoverFixture { - _temp_dir: temp_dir, - origin, - app_state, - session, - proxy_session_id, - lease_id, - request_url, - base_manifest, - asset_buffer, - asset, - } - } - - async fn apply_terminal_cutover_pressure(fixture: &PreparedTerminalCutoverFixture) { - let mut session = fixture.session.write().await; - session.origin_control.path_condition = HlsOriginPathCondition::HardFetchFailure; - let transition_buffer_seq = fixture.base_manifest.last_proxy_seq.saturating_add(1); - let commit_guard_seq = transition_buffer_seq.saturating_add(1); - for segment in - session.segments.values_mut().filter(|segment| segment.proxy_seq > fixture.base_manifest.last_proxy_seq) - { - if segment.proxy_seq == transition_buffer_seq { - segment.duration_ms = 1_000; - } else if segment.proxy_seq == commit_guard_seq { - segment.duration_ms = 2_800; - } else { - segment.status = SegmentCacheStatus::Expired; - } - } - session.advance_media_readiness_generation(); - } - - async fn terminal_cutover_acceptance_directive( - fixture: &PreparedTerminalCutoverFixture, - ) -> HlsManifestAcceptanceDirective { - apply_terminal_cutover_pressure(fixture).await; - let directive = match crate::api::model::hls_manifest_acceptance_directive_for_session( - &fixture.app_state.hls_ctx(), - &fixture.session, - &fixture.proxy_session_id, - ) - .await - { - HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => directive, - other => panic!("deterministic recovery-pressure snapshot must evaluate: {other:?}"), - }; - assert_eq!(directive.trigger, HlsManifestAcceptanceTrigger::RecoveryRequired); - let bundle_key = prepared_terminal_bundle_key( - &fixture.asset, - fixture.base_manifest.target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let timing = directive.timing_seed.expect("prepared acceptance timing seed"); - assert_eq!(timing.required_terminal_media_key, Some(bundle_key)); - assert_eq!(timing.terminal_media_preparation, HlsTerminalMediaPreparationState::Ready { key: bundle_key }); - directive - } - - async fn exhaust_terminal_cutover_recovery( - fixture: &PreparedTerminalCutoverFixture, - directive: HlsManifestAcceptanceDirective, - ) -> HlsAccessLease { - let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); - let requests_before = fixture.origin.manifest_request_count(); - let mut refresh = regression_origin_refresh_request( - &fixture.app_state, - Arc::clone(&fixture.session), - &fixture.request_url, - Some(fixture.lease_id.clone()), - ); - refresh.acceptance_directive = directive; - refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; - assert!(trigger_origin_refresh_sync(refresh).await); - assert!(fixture.origin.manifest_request_count().saturating_sub(requests_before) >= plan.total_candidates()); - apply_terminal_cutover_pressure(fixture).await; - let bundle_key = prepared_terminal_bundle_key( - &fixture.asset, - fixture.base_manifest.target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - { - let session = fixture.session.read().await; - let episode = session.origin_control.acceptance_episode.as_ref().expect("exhausted acceptance episode"); - assert_eq!(episode.completed_burst_candidates, plan.total_candidates()); - assert!(episode.full_burst_completed); - assert_eq!(episode.timing().required_terminal_media_key, Some(bundle_key)); - assert_eq!( - episode.timing().terminal_media_preparation, - HlsTerminalMediaPreparationState::Ready { key: bundle_key } - ); - assert!(episode.exhaustion_reason().is_some()); - } - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("pressured live lease"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - assert_eq!( - lease.playback_cursor.highest_contiguous_completed_proxy_seq, - Some(fixture.base_manifest.last_proxy_seq) - ); - { - let session = fixture.session.read().await; - let ready_after_tail = session - .segments - .values() - .filter(|segment| segment.proxy_seq > fixture.base_manifest.last_proxy_seq) - .filter_map(|segment| { - matches!(segment.status, SegmentCacheStatus::Ready { .. }) - .then_some((segment.proxy_seq, segment.duration_ms)) - }) - .collect::>(); - assert_eq!( - ready_after_tail, - vec![ - (fixture.base_manifest.last_proxy_seq.saturating_add(1), 1_000), - (fixture.base_manifest.last_proxy_seq.saturating_add(2), 2_800), - ] - ); - assert!(session.origin_control.path_condition.is_degraded()); - } - lease - } - - async fn commit_prepared_terminal_cutover( - fixture: &PreparedTerminalCutoverFixture, - pressured_lease: &HlsAccessLease, - ) -> (u64, u64) { - let cutover_now_ms = pressured_lease - .playback_cursor - .first_segment_completed_at_ms - .expect("measured lease playback start") - .saturating_add(fixture.base_manifest.playlist_duration_ms); - let first = commit_terminal_tail_if_lease_reserve_requires_cutover( - &fixture.app_state.hls_ctx(), - &fixture.session, - &fixture.proxy_session_id, - pressured_lease, - cutover_now_ms, - ) - .await; - assert_eq!(first, HlsTerminalResolution::Committed); - let terminal_lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("terminal lease remains stored"); - let second = commit_terminal_tail_if_lease_reserve_requires_cutover( - &fixture.app_state.hls_ctx(), - &fixture.session, - &fixture.proxy_session_id, - &terminal_lease, - cutover_now_ms.saturating_add(1), - ) - .await; - assert_eq!(second, HlsTerminalResolution::Committed); - let HlsLeasePlaybackMode::TerminalTail(plan) = terminal_lease.playback_mode else { - panic!("prepared terminal tail must commit"); - }; - assert_eq!(plan.segment_count, HLS_TERMINAL_TAIL_SEGMENT_COUNT); - (plan.generation.0, cutover_now_ms) - } - - async fn assert_prepared_terminal_cutover_manifest(fixture: &PreparedTerminalCutoverFixture, generation: u64) { - let manifest_uri = - format!("/hls/shared/live/{}/{}/manifest.m3u8", fixture.proxy_session_id.0, fixture.lease_id.0); - let response = get_response(Arc::clone(&fixture.app_state), &manifest_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest utf8"); - let live_tail_path = format!( - "/{}/{}/{:06}.ts", - fixture.proxy_session_id.0, fixture.lease_id.0, fixture.base_manifest.last_proxy_seq - ); - let terminal_prefix = format!("/{}/{}/terminal/{generation}/", fixture.proxy_session_id.0, fixture.lease_id.0); - assert!(body.contains(&live_tail_path)); - assert_eq!(body.matches("#EXT-X-DISCONTINUITY\n").count(), 1); - assert!(body.find(&live_tail_path) < body.find("#EXT-X-DISCONTINUITY\n")); - let key_reset = body.find("#EXT-X-KEY:METHOD=NONE\n").expect("AES-to-clear key reset"); - let discontinuity = body.find("#EXT-X-DISCONTINUITY\n").expect("terminal discontinuity"); - assert!(key_reset < discontinuity); - assert_eq!(body.matches(&terminal_prefix).count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); - for index in 0..HLS_TERMINAL_TAIL_SEGMENT_COUNT { - assert!(body.contains(&format!("{terminal_prefix}{index}.ts"))); - } - let duration_ms = fixture.asset.duration_ms(); - let extinf = format!("#EXTINF:{}.{:03},", duration_ms / 1_000, duration_ms % 1_000); - assert_eq!(body.matches(&extinf).count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - let renders_before = fixture.asset_buffer.finite_hls_render_count(); - let zero_uri = format!( - "/hls/shared/live/{}/{}/terminal/{generation}/0.ts", - fixture.proxy_session_id.0, fixture.lease_id.0 - ); - let one_uri = format!( - "/hls/shared/live/{}/{}/terminal/{generation}/1.ts", - fixture.proxy_session_id.0, fixture.lease_id.0 - ); - let zero = response_body(get_response(Arc::clone(&fixture.app_state), &zero_uri, None).await).await; - let one = response_body(get_response(Arc::clone(&fixture.app_state), &one_uri, None).await).await; - assert_ne!(zero, one); - assert_eq!(fixture.asset_buffer.finite_hls_render_count(), renders_before); - } - - async fn assert_terminal_cutover_sticky_after_recovery( - fixture: &PreparedTerminalCutoverFixture, - generation: u64, - cutover_now_ms: u64, - ) { - { - let mut session = fixture.session.write().await; - let recovered_manifest = - normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:83\n#EXT-X-TARGETDURATION:12\n#EXTINF:12.0,\n83.ts\n"); - session.apply_origin_manifest(&recovered_manifest).expect("later shared-session recovery"); - session.origin_control.record_media_progress(cutover_now_ms.saturating_add(1), 12_000); - } - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("sticky terminal lease"); - assert!(matches!( - lease.playback_mode, - HlsLeasePlaybackMode::TerminalTail(ref plan) if plan.generation.0 == generation - )); - } - - #[tokio::test] - async fn commits_prepared_terminal_tail_once_when_recovery_misses_deadline() { - let fixture = prepared_terminal_cutover_fixture().await; - let directive = terminal_cutover_acceptance_directive(&fixture).await; - let pressured_lease = exhaust_terminal_cutover_recovery(&fixture, directive).await; - let (generation, cutover_now_ms) = commit_prepared_terminal_cutover(&fixture, &pressured_lease).await; - assert_prepared_terminal_cutover_manifest(&fixture, generation).await; - assert_terminal_cutover_sticky_after_recovery(&fixture, generation, cutover_now_ms).await; - } - - #[tokio::test] - async fn warm_fmp4_map_cutover_without_ready_reserve_fails_closed_without_a_ts_splice() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - enable_channel_unavailable_custom_response(&app_state); - let proxy_session_id = ProxySessionId(map_hls_map(&app_state, b"fmp4-init", true).await); - let lease_id = HlsAccessLeaseId(format!("test-access-lease-{}", proxy_session_id.0)); - let now_ms = super::current_time_millis(); - let (base_proxy_seq, duration_ms) = { - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("fMP4 shared session"); - let session = session.read().await; - let entry = session.segments.values().next().expect("fMP4 media entry"); - (entry.proxy_seq, entry.duration_ms) - }; - let snapshot = HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, - source_commit_identity: HlsManifestCommitIdentity::new(now_ms), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 0, - delivered_at_ms: now_ms, - first_proxy_seq: base_proxy_seq, - last_proxy_seq: base_proxy_seq, - visible_segments: Arc::from([HlsLeaseManifestSegment { - proxy_seq: base_proxy_seq, - duration_ms, - uri: format!("/iptv/hls/shared/live/{}/{}/{base_proxy_seq:06}.m4s", proxy_session_id.0, lease_id.0) - .into(), - discontinuity_before: false, - map_ref_ready: true, - encryption: None, - }]), - discontinuity_sequence: 0, - target_duration_ms: terminal_test_asset().duration_ms().saturating_add(1_000), - playlist_duration_ms: duration_ms, - last_visible_media_end_ms: duration_ms, - active_map: Some(HlsMapSignature { fingerprint: [7; 32], container: HlsMediaContainer::FragmentedMp4 }), - active_encryption: None, - container: HlsMediaContainer::FragmentedMp4, - }; - publish_test_manifest_and_exhaust_configured_acceptance( - &app_state, - &proxy_session_id, - &lease_id, - snapshot, - now_ms, - ) - .await; - let manifest_uri = format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, lease_id.0); - - let response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert!(!response.headers().contains_key(header::LOCATION)); - assert!(response_body(response).await.is_empty()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("failed-closed lease snapshot"); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - let terminal_uri = format!("/hls/shared/live/{}/{}/terminal/1/0.ts", proxy_session_id.0, lease_id.0); - let terminal_response = get_response(app_state, &terminal_uri, None).await; - assert_eq!(terminal_response.status(), StatusCode::NOT_FOUND); - assert!(!terminal_response.headers().contains_key(header::LOCATION)); - assert!(response_body(terminal_response).await.is_empty()); - } - - async fn terminal_head_content_length( - app_state: &Arc, - proxy_session_id: &str, - segment_uri: &str, - ) -> usize { - let head_response = request_response(Arc::clone(app_state), Method::HEAD, segment_uri, None).await; - assert_eq!(head_response.status(), StatusCode::OK); - assert_eq!(head_response.headers()[header::CONTENT_TYPE], "video/mp2t"); - assert_eq!(head_response.headers()[header::ACCEPT_RANGES], "bytes"); - assert!(head_response.headers()[header::CACHE_CONTROL].to_str().is_ok_and(|value| value.contains("immutable"))); - let content_length = head_response.headers()[header::CONTENT_LENGTH] - .to_str() - .expect("HEAD content length") - .parse::() - .expect("HEAD content length value"); - assert!(content_length > 0); - assert!(response_body(head_response).await.is_empty()); - assert_eq!(hls_session_last_media_at_ms(app_state, proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(app_state).await; - - let head_range = request_response(Arc::clone(app_state), Method::HEAD, segment_uri, Some("bytes=0-187")).await; - assert_eq!(head_range.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(head_range.headers()[header::CONTENT_LENGTH], "188"); - let expected_content_range = format!("bytes 0-187/{content_length}"); - assert_eq!(head_range.headers()[header::CONTENT_RANGE].to_str().ok(), Some(expected_content_range.as_str())); - assert!(response_body(head_range).await.is_empty()); - assert_eq!(hls_session_last_media_at_ms(app_state, proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(app_state).await; - content_length - } - - #[tokio::test] - async fn hls_terminal_response_serves_prepared_finite_full_and_range_bytes_per_index() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - let terminal_renderer = terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let renders_before_requests = terminal_renderer.finite_hls_render_count(); - let finalizations_before_requests = terminal_renderer.finite_hls_finalize_count(); - assert_eq!(renders_before_requests, usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); - assert_eq!(finalizations_before_requests, usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); - let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; - let segment_zero_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); - let segment_one_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/1.ts"); - let repair_before = app_state.hls_proxy.segment_repair().stats().await; - let provider_connections_before = app_state.active_provider.get_provider_connections_count().await; - - let head_content_length = terminal_head_content_length(&app_state, &proxy_session_id, &segment_zero_uri).await; - - let segment_zero_response = get_response(Arc::clone(&app_state), &segment_zero_uri, None).await; - assert_eq!(segment_zero_response.status(), StatusCode::OK); - assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); - assert!(!tuliprox_core::utils::response_compression::should_compress_response(&segment_zero_response)); - assert_eq!(segment_zero_response.headers()[header::CONTENT_TYPE], "video/mp2t"); - assert_eq!(segment_zero_response.headers()[header::ACCEPT_RANGES], "bytes"); - assert!(segment_zero_response.headers()[header::CACHE_CONTROL] - .to_str() - .is_ok_and(|value| value.contains("immutable"))); - let declared_length = segment_zero_response.headers()[header::CONTENT_LENGTH] - .to_str() - .expect("finite content length header") - .parse::() - .expect("finite content length value"); - assert_eq!(declared_length, head_content_length); - let segment_zero = response_body(segment_zero_response).await; - assert_eq!(segment_zero.len(), declared_length); - assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; - assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); - assert_eq!(app_state.hls_proxy.segment_repair().stats().await, repair_before); - assert_eq!(app_state.active_provider.get_provider_connections_count().await, provider_connections_before); - - let segment_zero_again = - response_body(get_response(Arc::clone(&app_state), &segment_zero_uri, None).await).await; - let segment_one = response_body(get_response(Arc::clone(&app_state), &segment_one_uri, None).await).await; - assert_eq!(segment_zero, segment_zero_again, "same terminal index is immutable"); - assert_ne!(segment_zero, segment_one, "successive terminal indices advance timestamps and continuity"); - - let range_response = get_response(Arc::clone(&app_state), &segment_zero_uri, Some("bytes=0-187")).await; - assert_eq!(range_response.status(), StatusCode::PARTIAL_CONTENT); - assert!(!tuliprox_core::utils::response_compression::should_compress_response(&range_response)); - assert_eq!(range_response.headers()[header::CONTENT_LENGTH], "188"); - let expected_content_range = format!("bytes 0-187/{declared_length}"); - assert_eq!( - range_response.headers()[header::CONTENT_RANGE].to_str().ok(), - Some(expected_content_range.as_str()) - ); - assert_eq!(range_response.headers()[header::ACCEPT_RANGES], "bytes"); - assert!(range_response.headers()[header::CACHE_CONTROL] - .to_str() - .is_ok_and(|value| value.contains("immutable"))); - assert_eq!(response_body(range_response).await, segment_zero.slice(..188)); - - let unsatisfiable_range = format!("bytes={declared_length}-"); - let unsatisfiable_response = get_response(app_state, &segment_zero_uri, Some(&unsatisfiable_range)).await; - assert_eq!(unsatisfiable_response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert!(!tuliprox_core::utils::response_compression::should_compress_response(&unsatisfiable_response)); - let expected_unsatisfied_content_range = format!("bytes */{declared_length}"); - assert_eq!( - unsatisfiable_response.headers()[header::CONTENT_RANGE].to_str().ok(), - Some(expected_unsatisfied_content_range.as_str()) - ); - assert!(response_body(unsatisfiable_response).await.is_empty()); - assert_eq!( - terminal_renderer.finite_hls_render_count(), - renders_before_requests, - "terminal HTTP serving must not invoke the TS writer again" - ); - assert_eq!( - terminal_renderer.finite_hls_finalize_count(), - finalizations_before_requests, - "terminal HTTP serving must not invoke lease-specific TS finalization again" - ); - } - - async fn assert_runtime_policy_terminal_segments( - fixture: &RuntimePolicyEndpointFixture, - terminal_prefix: &str, - segment_count: u16, - ) { - for index in [0, 1, segment_count.saturating_sub(1)] { - let uri = format!("{terminal_prefix}{index}.ts"); - let response = get_response(Arc::clone(&fixture.app_state), &uri, None).await; - assert_eq!(response.status(), StatusCode::OK, "terminal segment {index}"); - assert!(!response_body(response).await.is_empty()); - } - } - - #[tokio::test] - async fn resource_access_denial_commits_and_preserves_user_exhausted_tail() { - let fixture = runtime_policy_endpoint_fixture(true).await; - let session = fixture - .app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&fixture.proxy_session_id) - .await - .expect("runtime policy session"); - let origin_refresh_before = session.read().await.origin_refresh.clone(); - mark_hls_user_session_exhausted(&fixture.app_state).await; - - let denied_live = get_response(Arc::clone(&fixture.app_state), &fixture.live_segment_uri, None).await; - assert_eq!(denied_live.status(), StatusCode::FORBIDDEN); - let revoking = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("revoking lease snapshot"); - assert_eq!(revoking.state, HlsAccessLeaseState::PolicyRevoking); - assert_eq!(revoking.playback_mode, HlsLeasePlaybackMode::Live); - assert_eq!( - revoking.runtime_policy_revocation.as_ref().map(|revocation| revocation.reason), - Some(HlsRuntimeCustomTailReason::UserConnectionsExhausted) - ); - - let pending_manifest = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(pending_manifest.status(), StatusCode::SERVICE_UNAVAILABLE); - let committed_plan = wait_for_runtime_policy_terminal_plan(&fixture).await; - let manifest_response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(manifest_response.status(), StatusCode::OK); - assert!(!manifest_response.headers().contains_key(header::LOCATION)); - let manifest = String::from_utf8(response_body(manifest_response).await.to_vec()) - .expect("runtime policy terminal manifest utf8"); - assert!(manifest.ends_with("#EXT-X-ENDLIST\n")); - assert!(!manifest.contains("/cvs/hls/")); - - let committed = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("committed runtime policy lease"); - assert_eq!(committed.state, HlsAccessLeaseState::Denied); - let HlsLeasePlaybackMode::TerminalTail(plan) = committed.playback_mode else { - panic!("resource denial must commit a finite terminal plan"); - }; - assert_eq!(plan.generation, committed_plan.generation); - assert_eq!(plan.reason, HlsRuntimeCustomTailReason::UserConnectionsExhausted); - assert!(plan.segment_count >= 2); - let terminal_prefix = format!( - "/hls/shared/live/{}/{}/terminal/{}/", - fixture.proxy_session_id.0, fixture.lease_id.0, plan.generation.0 - ); - assert_eq!(manifest.matches(&terminal_prefix).count(), usize::from(plan.segment_count)); - - assert_runtime_policy_terminal_segments(&fixture, &terminal_prefix, plan.segment_count).await; - let replay = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(replay.status(), StatusCode::OK); - assert_eq!( - String::from_utf8(response_body(replay).await.to_vec()).expect("replayed runtime policy manifest utf8"), - manifest - ); - assert_eq!(get_status(Arc::clone(&fixture.app_state), &fixture.live_segment_uri).await, StatusCode::FORBIDDEN); - let retained = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("retained runtime policy plan"); - assert!(matches!( - retained.playback_mode, - HlsLeasePlaybackMode::TerminalTail(ref current) - if current.generation == plan.generation - && current.reason == HlsRuntimeCustomTailReason::UserConnectionsExhausted - )); - assert_eq!(session.read().await.origin_refresh, origin_refresh_before); - } - - #[tokio::test] - async fn manifest_touch_denied_replays_policy_tail_instead_of_standalone_clock() { - let fixture = runtime_policy_endpoint_fixture(true).await; - let _ = fixture - .app_state - .hls_proxy - .begin_runtime_policy_revocation( - &fixture.lease_id, - &fixture.proxy_session_id, - HlsRuntimeCustomTailReason::UserConnectionsExhausted, - super::current_time_millis(), - ) - .await; - assert_eq!( - fixture - .app_state - .hls_proxy - .touch_manifest_access_lease( - &fixture.lease_id, - &fixture.proxy_session_id, - super::current_time_millis(), - None, - None, - 60_000, - ) - .await, - HlsAccessLeaseTouch::Denied - ); - - let pending = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(pending.status(), StatusCode::SERVICE_UNAVAILABLE); - let committed_plan = wait_for_runtime_policy_terminal_plan(&fixture).await; - let response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("policy touch manifest utf8"); - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("touch-denied lease"); - let HlsLeasePlaybackMode::TerminalTail(plan) = lease.playback_mode else { - panic!("touch denial must retain a lease-bound terminal plan"); - }; - assert_eq!(plan.generation, committed_plan.generation); - assert!(body.contains(&format!( - "/hls/shared/live/{}/{}/terminal/{}/0.ts", - fixture.proxy_session_id.0, fixture.lease_id.0, plan.generation.0 - ))); - assert!(!body.contains("/cvs/hls/")); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - } - - #[tokio::test] - async fn cold_user_denial_uses_standalone_finite_response() { - let fixture = runtime_policy_endpoint_fixture(false).await; - mark_hls_user_session_exhausted(&fixture.app_state).await; - - let denial = get_response(Arc::clone(&fixture.app_state), &fixture.live_segment_uri, None).await; - assert_eq!(denial.status(), StatusCode::FORBIDDEN); - let denied = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("cold denied lease"); - assert_eq!(denied.state, HlsAccessLeaseState::Denied); - assert_eq!(denied.playback_mode, HlsLeasePlaybackMode::Ended); - assert!(denied.runtime_policy_revocation.is_none()); - assert_eq!(denied.runtime_policy_denial_reason(), Some(HlsRuntimeCustomTailReason::UserConnectionsExhausted)); - - let response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone policy manifest utf8"); - assert!(body.contains(&format!("/cvs/hls/{}/", fixture.lease_id.0))); - assert!(!body.contains("/hls-user/")); - assert!(!body.contains("/hls-pass/")); - assert!(!body.contains("/user_connections_exhausted/")); - assert!(!body - .contains(&format!("/hls/shared/live/{}/{}/terminal/", fixture.proxy_session_id.0, fixture.lease_id.0))); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - } - - #[tokio::test] - async fn hls_terminal_response_body_after_lease_denial_does_not_extend_shared_session_activity() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("terminal session"); - let segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); - let response = get_response(Arc::clone(&app_state), &segment_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - let activity_after_authorized_response = - session.read().await.activity.last_authorized_media_at_ms.expect("terminal GET marks media activity"); - - let _ = app_state - .hls_proxy - .deny_access_lease( - &HlsAccessLeaseId(lease_id), - tuliprox_hls::HlsAccessLeaseDenialMode::PreserveCommittedFiniteTail, - ) - .await; - assert!(!response_body(response).await.is_empty()); - - assert_eq!( - session.read().await.activity.last_authorized_media_at_ms, - Some(activity_after_authorized_response), - "body completion after denial must not extend activity" - ); - } - - #[tokio::test] - async fn hls_terminal_response_rejects_stale_malformed_and_out_of_bounds_paths() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let (generation, segment_count) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; - let stale_generation = generation.saturating_add(1); - let stale_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{stale_generation}/0.ts"); - let out_of_bounds_uri = - format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/{segment_count}.ts"); - - let malformed_generation = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/01/0.ts"); - let non_numeric_generation = - format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/not-a-generation/0.ts"); - let overflowing_generation = - format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/18446744073709551616/0.ts"); - let malformed_file = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/00.ts"); - let wrong_extension = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.m4s"); - - for uri in [ - stale_uri, - out_of_bounds_uri, - malformed_generation, - non_numeric_generation, - overflowing_generation, - malformed_file, - wrong_extension, - ] { - let response = get_response(Arc::clone(&app_state), &uri, None).await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - } - } - - #[tokio::test] - async fn expired_route_replays_already_committed_custom_tail() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; - let proxy_session_key = ProxySessionId(proxy_session_id.clone()); - let lease_key = HlsAccessLeaseId(lease_id.clone()); - let expired_at_ms = super::current_time_millis().saturating_sub(1); - { - let mut leases = app_state.hls_proxy.access_leases().write().await; - let mut lease = leases.remove_access_lease(&lease_key).expect("terminal lease exists"); - lease.valid_until_ms = expired_at_ms; - leases.prepare_access_lease(lease); - } - let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/manifest.m3u8"); - let segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); - - let manifest_response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; - let segment_response = get_response(Arc::clone(&app_state), &segment_uri, None).await; - - assert_eq!(manifest_response.status(), StatusCode::OK); - assert!(!manifest_response.headers().contains_key(header::LOCATION)); - assert!(String::from_utf8(response_body(manifest_response).await.to_vec()) - .expect("expired committed manifest utf8") - .ends_with("#EXT-X-ENDLIST\n")); - assert_eq!(segment_response.status(), StatusCode::OK); - assert!(!response_body(segment_response).await.is_empty()); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_key) - .await - .expect("terminal session exists"); - assert!(session.read().await.terminal_tail_protection(&lease_key).is_some()); - - let cleanup_at_ms = super::current_time_millis(); - app_state - .hls_proxy - .handle_lifecycle_event( - &app_state.active_users, - &app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::AccessLeaseValidity { - lease_id: lease_key.clone(), - proxy_session_id: proxy_session_key, - }, - due_at_ms: cleanup_at_ms, - }, - cleanup_at_ms, - ) - .await; - - assert!(!session.read().await.has_terminal_tail_protections()); - assert!(app_state - .hls_proxy - .access_lease_response_snapshot(&lease_key, &ProxySessionId(proxy_session_id), cleanup_at_ms) - .await - .is_none()); - assert_eq!(get_response(Arc::clone(&app_state), &manifest_uri, None).await.status(), StatusCode::NOT_FOUND); - assert_eq!(get_response(app_state, &segment_uri, None).await.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn expired_route_without_session_evidence_returns_not_found_instead_of_unanchored_manifest() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"live-without-lease").await; - enable_channel_unavailable_custom_response(&app_state); - let missing_lease = "expired-without-base-evidence"; - let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{missing_lease}/manifest.m3u8"); - - let response = get_response(app_state, &manifest_uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - assert!(response_body(response).await.is_empty()); - } - - #[tokio::test] - async fn hls_terminal_response_normal_segment_map_and_resource_routes_never_serve_terminal_fallbacks() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; - let normal_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/000123.ts"); - - let normal_segment_response = get_response(Arc::clone(&app_state), &normal_segment_uri, None).await; - - assert_eq!(normal_segment_response.status(), StatusCode::OK); - assert!(!normal_segment_response.headers().contains_key(header::LOCATION)); - assert_eq!(response_body(normal_segment_response).await, bytes::Bytes::from_static(b"original-live-tail")); - - let map_temp_dir = tempfile::tempdir().expect("map tempdir"); - let map_app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(map_temp_dir.path(), 300))); - let map_proxy_session_id = map_hls_map(&map_app_state, b"original-map", true).await; - let map_lease_id = format!("test-access-lease-{map_proxy_session_id}"); - let map_session = map_app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(map_proxy_session_id.clone())) - .await - .expect("map session exists"); - let map_base_proxy_seq = *map_session.read().await.segments.keys().next().expect("map manifest has media"); - terminalize_existing_test_lease(&map_app_state, &map_proxy_session_id, &map_lease_id, map_base_proxy_seq).await; - let map_uri = format!("/hls/shared/live/{map_proxy_session_id}/{map_lease_id}/map/000000.mp4"); - let map_response = get_response(map_app_state, &map_uri, None).await; - assert_eq!(map_response.status(), StatusCode::NOT_FOUND); - assert!(!map_response.headers().contains_key(header::LOCATION)); - assert!(response_body(map_response).await.is_empty()); - - let resource_temp_dir = tempfile::tempdir().expect("resource tempdir"); - let resource_app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings( - resource_temp_dir.path(), - 300, - ))); - let (resource_proxy_session_id, resource_id) = - map_transient_resource(&resource_app_state, "http://origin.example.com/old.ts", "ts", true).await; - let resource_lease_id = format!("test-access-lease-{resource_proxy_session_id}"); - terminalize_existing_test_lease(&resource_app_state, &resource_proxy_session_id, &resource_lease_id, 0).await; - let resource_uri = - format!("/hls/shared/live/{resource_proxy_session_id}/{resource_lease_id}/r/{resource_id}.ts"); - let resource_response = get_response(resource_app_state, &resource_uri, None).await; - assert_eq!(resource_response.status(), StatusCode::NOT_FOUND); - assert!(!resource_response.headers().contains_key(header::LOCATION)); - assert!(response_body(resource_response).await.is_empty()); - } - - #[tokio::test] - async fn delayed_live_resource_completion_revalidates_after_terminal_cutover_without_a_sleep() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"ready-before-cutover").await; - let lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); - let proxy_session_key = ProxySessionId(proxy_session_id.clone()); - let access_context = test_hls_access_context(proxy_session_key.clone(), lease_id.clone()); - let live_identity = app_state - .hls_proxy - .access_lease_response_snapshot(&lease_id, &proxy_session_key, super::current_time_millis()) - .await - .and_then(|lease| lease.media_identity()) - .expect("live lease identity"); - let (release_sender, release_receiver) = tokio::sync::oneshot::channel(); - let app_state_for_completion = Arc::clone(&app_state); - let access_context_for_completion = access_context.clone(); - let completion = tokio::spawn(async move { - let _ = release_receiver.await; - super::hls_live_lease_identity_is_current( - &app_state_for_completion, - &access_context_for_completion, - live_identity, - ) - .await - }); - - terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id.0, 123).await; - release_sender.send(()).expect("release delayed completion after cutover"); - - assert!(!completion.await.expect("controlled completion task")); - } - - async fn prepare_other_live_lease( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - now_ms: u64, - ) -> HlsAccessLeaseId { - let lease_id = HlsAccessLeaseId("other-live-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - 60_000, - )) - .await; - lease_id - } - - fn assert_terminal_plan_unchanged( - playback_mode: &HlsLeasePlaybackMode, - expected_generation: HlsTerminalTailGeneration, - terminal_path: HlsTerminalSegmentPath, - expected_bytes: &bytes::Bytes, - ) { - let HlsLeasePlaybackMode::TerminalTail(plan) = playback_mode else { - panic!("shared lease operation cannot reactivate the terminal lease"); - }; - assert_eq!(plan.generation, expected_generation); - assert_eq!(plan.segment_bytes(terminal_path).as_ref(), Some(expected_bytes)); - } - - async fn assert_conflicted_standalone_fallback( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - ) { - let response = - super::hls_unpublished_lease_channel_unavailable_response(app_state, proxy_session_id, lease_id).await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - assert!(!response.headers().contains_key(header::RETRY_AFTER)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone manifest utf8"); - assert!(body.contains("#EXT-X-ENDLIST")); - assert!( - !body.contains("/hls/shared/live/"), - "standalone response must not expose a normal segment without a readiness path" - ); - assert_eq!( - session.read().await.origin_control.path_condition, - HlsOriginPathCondition::AcceptanceConflict, - "lease-local fallback must not relax deterministic conflict evidence" - ); - } - - #[tokio::test] - async fn reused_conflicted_session_standalone_fallback_preserves_terminal_lease_during_recovery() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - enable_channel_unavailable_custom_response(&app_state); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; - let terminal_lease_id = format!("test-access-lease-{proxy_session_id}"); - terminalize_existing_test_lease(&app_state, &proxy_session_id, &terminal_lease_id, 123).await; - let proxy_session_key = ProxySessionId(proxy_session_id.clone()); - let now_ms = super::current_time_millis(); - let terminal_before = app_state - .hls_proxy - .access_lease_response_snapshot(&HlsAccessLeaseId(terminal_lease_id.clone()), &proxy_session_key, now_ms) - .await - .expect("terminal lease exists before the other lease"); - let HlsLeasePlaybackMode::TerminalTail(terminal_plan_before) = terminal_before.playback_mode else { - panic!("original lease is terminal"); - }; - let terminal_path = HlsTerminalSegmentPath { generation: terminal_plan_before.generation, index: 0 }; - let terminal_bytes_before = - terminal_plan_before.segment_bytes(terminal_path).expect("terminal segment zero is immutable"); - let other_lease_id = prepare_other_live_lease(&app_state, &proxy_session_key, now_ms).await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_key) - .await - .expect("shared session exists"); - session.write().await.origin_control.path_condition = HlsOriginPathCondition::AcceptanceConflict; - - assert_conflicted_standalone_fallback(&app_state, &session, &proxy_session_key, &other_lease_id).await; - - let terminal_after_fallback = app_state - .hls_proxy - .access_lease_response_snapshot( - &HlsAccessLeaseId(terminal_lease_id.clone()), - &proxy_session_key, - now_ms.saturating_add(1), - ) - .await - .expect("terminal lease remains stored after standalone fallback"); - assert_terminal_plan_unchanged( - &terminal_after_fallback.playback_mode, - terminal_plan_before.generation, - terminal_path, - &terminal_bytes_before, - ); - - assert!(app_state - .hls_proxy - .activate_access_lease( - &other_lease_id, - &proxy_session_key, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 5_000, valid_window_ms: 60_000 }, - ) - .await - .is_activated()); - - let recovered_manifest = - normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:124\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\n124.ts\n"); - { - let mut session = session.write().await; - session.apply_origin_manifest(&recovered_manifest).expect("recovery manifest commits"); - session.origin_control.record_media_progress(now_ms.saturating_add(1), 4_000); - } - - let terminal = app_state - .hls_proxy - .access_lease_response_snapshot( - &HlsAccessLeaseId(terminal_lease_id), - &proxy_session_key, - now_ms.saturating_add(2), - ) - .await - .expect("terminal lease remains stored"); - let other = app_state - .hls_proxy - .access_lease_response_snapshot(&other_lease_id, &proxy_session_key, now_ms.saturating_add(2)) - .await - .expect("other live lease remains stored"); - assert_terminal_plan_unchanged( - &terminal.playback_mode, - terminal_plan_before.generation, - terminal_path, - &terminal_bytes_before, - ); - assert_eq!(other.playback_mode, HlsLeasePlaybackMode::Live); - } - - #[test] - fn hls_custom_video_manifest_uses_live_six_segment_window_for_provisioning() { - let user = hls_custom_video_test_user(); - let manifest = build_hls_custom_video_manifest_body( - "https://example.test/iptv", - &user, - CustomVideoStreamType::Provisioning, - ) - .expect("provisioning manifest does not depend on one looping asset duration"); - - assert!(manifest.contains("#EXT-X-TARGETDURATION:2")); - assert!(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")); - assert!(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-INDEPENDENT-SEGMENTS\n")); - assert!(!manifest.contains("#EXT-X-DISCONTINUITY-SEQUENCE")); - assert!(!manifest.contains("#EXT-X-SESSION-DATA")); - assert!(!manifest.contains("#EXT-X-ENDLIST")); - assert!(!manifest.contains("#EXT-X-DISCONTINUITY\n")); - assert_eq!(manifest.matches("#EXTINF:2.000000,").count(), 6); - for index in 0..6 { - assert!(manifest - .contains(&format!("https://example.test/iptv/cvs/hls/viewer/secret/provisioning_{index:03}.ts"))); - } - assert!(!manifest.contains("https://example.test/iptv/cvs/hls/viewer/secret/provisioning.ts")); - assert_eq!( - crate::api::model::hls_panel_provisioning_manifest_path(&user, 80510), - "/cvs/hls/viewer/secret/provisioning.m3u8?id=80510" - ); - assert!(!manifest.contains("provisioning.ts?")); - assert!(!manifest.contains("virtual_id")); - assert!(!manifest.contains("&seq=")); - } - - #[test] - fn hls_response_uses_rfc8216_content_type_and_remains_tower_compressible() { - let response = super::hls_response("#EXTM3U\n".to_string()).into_response(); - - assert_eq!(response.headers().get(header::CONTENT_TYPE).unwrap(), "application/vnd.apple.mpegurl"); - assert!(tuliprox_core::utils::response_compression::should_compress_response(&response)); - } - - #[test] - fn virtual_hls_entry_path_uses_single_manifest_extension() { - let user = hls_custom_video_test_user(); - let xtream_target = ConfigTarget::from(&shared::model::ConfigTargetDto { - output: vec![shared::model::TargetOutputDto::Xtream(shared::model::XtreamTargetOutputDto::default())], - ..Default::default() - }); - let xtream_input = ConfigInput { input_type: InputType::Xtream, ..ConfigInput::default() }; - let m3u_target = ConfigTarget::from(&shared::model::ConfigTargetDto::default()); - let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() }; - - let xtream_path = super::build_virtual_hls_entry_path(&xtream_target, &xtream_input, &user, 59); - let m3u_path = super::build_virtual_hls_entry_path(&m3u_target, &m3u_input, &user, 59); - - assert_eq!(xtream_path, "/live/viewer/secret/59.m3u8"); - assert_eq!(m3u_path, "/m3u-stream/live/viewer/secret/59.m3u8"); - assert!(!xtream_path.contains("..m3u8")); - assert!(!m3u_path.contains("..m3u8")); - } - - #[test] - fn hls_manifest_headers_apply_disabled_headers_and_default_user_agent_policy() { - let mut input_headers = HashMap::new(); - input_headers.insert("User-Agent".to_string(), "Input-UA".to_string()); - input_headers.insert("Accept-Language".to_string(), "de".to_string()); - input_headers.insert("Accept-Encoding".to_string(), "gzip".to_string()); - input_headers.insert("Authorization".to_string(), "Bearer input-secret".to_string()); - input_headers.insert("X-Origin-Secret".to_string(), "input-secret".to_string()); - - let mut request_headers = HeaderMap::new(); - request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-")); - request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); - request_headers.insert(header::USER_AGENT, HeaderValue::from_static("Client-UA")); - request_headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer client-secret")); - request_headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret")); - request_headers - .insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); - request_headers.insert(header::HOST, HeaderValue::from_static("proxy.example.com")); - request_headers.insert(HeaderName::from_static("x-blocked"), HeaderValue::from_static("client")); - request_headers.insert(HeaderName::from_static("cf-ray"), HeaderValue::from_static("cf")); - - let disabled = ReverseProxyDisabledHeaderConfig { - referer_header: false, - x_header: true, - cloudflare_header: true, - custom_header: vec!["X-Origin-Secret".to_string()], - }; - let headers = build_hls_manifest_request_headers( - &input_headers, - &request_headers, - Some(&disabled), - Some("Default-UA"), - Some("Channel-UA"), - ); - - assert_eq!(headers.get(header::USER_AGENT).expect("user agent"), "Channel-UA"); - assert_eq!(headers.get("accept-language").expect("accept language"), "de"); - assert_eq!(headers.get(header::ACCEPT_ENCODING).expect("accept encoding"), "identity"); - assert!(!headers.contains_key(header::RANGE)); - assert!(!headers.contains_key(header::AUTHORIZATION)); - assert!(!headers.contains_key(header::COOKIE)); - assert!(!headers.contains_key("proxy-authorization")); - assert!(!headers.contains_key(header::HOST)); - assert!(!headers.contains_key("x-origin-secret")); - assert!(!headers.contains_key("x-blocked")); - assert!(!headers.contains_key("cf-ray")); - } - - #[tokio::test] - async fn legacy_hls_manifest_decodes_supported_origin_codings_and_enforces_identity() { - const MANIFEST: &str = "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\nsegment.ts\n"; - - for coding in ["gzip", "deflate", "br", "zstd"] { - let encoded = encode_test_manifest(coding, MANIFEST.as_bytes()).await; - let origin = spawn_test_encoded_manifest_origin(Some(coding), encoded, Duration::ZERO).await; - let input = legacy_manifest_test_input(&origin); - let client_headers = legacy_manifest_test_client_headers(); - - let (manifest, final_url, _) = - super::download_legacy_hls_manifest(&test_app_state(), &input, &client_headers) - .await - .unwrap_or_else(|error| panic!("{coding} manifest should decode: {error}")); - - assert_eq!(manifest, MANIFEST, "coding={coding}"); - assert_eq!(final_url, input.url, "coding={coding}"); - let requests = origin.requests.lock().await; - assert_eq!(requests.len(), 1, "coding={coding}"); - assert!( - requests[0].to_ascii_lowercase().contains("\r\naccept-encoding: identity\r\n"), - "coding={coding}, request={}", - requests[0] - ); - } - } - - #[tokio::test] - async fn legacy_hls_manifest_handles_identity_and_headerless_gzip_magic() { - const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-TARGETDURATION:4\n"; - let cases = [("identity", MANIFEST.to_vec()), ("gzip-magic", encode_test_manifest("gzip", MANIFEST).await)]; - - for (case, body) in cases { - let origin = spawn_test_encoded_manifest_origin(None, body, Duration::ZERO).await; - let input = legacy_manifest_test_input(&origin); - - let (manifest, _, _) = - super::download_legacy_hls_manifest(&test_app_state(), &input, &legacy_manifest_test_client_headers()) - .await - .unwrap_or_else(|error| panic!("{case} manifest should decode: {error}")); - - assert_eq!(manifest.as_bytes(), MANIFEST, "case={case}"); - } - } - - #[tokio::test] - async fn legacy_hls_manifest_limit_applies_after_decompression() { - let decoded = vec![b'x'; MAX_HLS_MANIFEST_BYTES + 1]; - let origin = spawn_test_encoded_manifest_origin( - Some("gzip"), - encode_test_manifest("gzip", &decoded).await, - Duration::ZERO, - ) - .await; - let input = legacy_manifest_test_input(&origin); - - let error = - super::download_legacy_hls_manifest(&test_app_state(), &input, &legacy_manifest_test_client_headers()) - .await - .expect_err("decoded manifest above limit must fail"); - - assert!(matches!( - error.get_ref().and_then(|source| source.downcast_ref()), - Some(crate::utils::content_coding::ContentBodyReadError::LimitExceeded { limit }) - if *limit == MAX_HLS_MANIFEST_BYTES - )); - } - - #[tokio::test] - async fn legacy_hls_manifest_deadline_includes_full_body_read() { - let origin = spawn_test_encoded_manifest_origin(None, b"#EXTM3U\n".to_vec(), Duration::from_millis(100)).await; - let input = legacy_manifest_test_input(&origin); - let hls_config = HlsCacheConfig::from(&HlsCacheConfigDto { - origin_manifest_timeout_ms: shared::model::Millis::new(10), - ..Default::default() - }); - let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); - - let error = super::download_legacy_hls_manifest(&app_state, &input, &legacy_manifest_test_client_headers()) - .await - .expect_err("complete manifest body read must honor the deadline"); - - assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); - } - - #[tokio::test] - async fn legacy_hls_manifest_distinguishes_invalid_utf8_from_decoder_failure() { - let invalid_utf8_origin = spawn_test_encoded_manifest_origin(None, vec![0xff], Duration::ZERO).await; - let invalid_utf8_input = legacy_manifest_test_input(&invalid_utf8_origin); - let invalid_utf8 = super::download_legacy_hls_manifest( - &test_app_state(), - &invalid_utf8_input, - &legacy_manifest_test_client_headers(), - ) - .await - .expect_err("invalid UTF-8 must fail"); - - let corrupt_origin = - spawn_test_encoded_manifest_origin(Some("gzip"), vec![0x1f, 0x8b, 0x08, 0x00], Duration::ZERO).await; - let corrupt_input = legacy_manifest_test_input(&corrupt_origin); - let decoder_failure = super::download_legacy_hls_manifest( - &test_app_state(), - &corrupt_input, - &legacy_manifest_test_client_headers(), - ) - .await - .expect_err("corrupt gzip must fail"); - - assert_eq!(invalid_utf8.kind(), std::io::ErrorKind::InvalidData); - assert!(crate::utils::content_coding::content_decoding_error_from_io(&decoder_failure).is_some()); - } - - #[test] - fn hls_proxy_public_path_prefix_rewrites_only_proxy_hls_uri_surfaces() { - let body = concat!( - "#EXTM3U\n", - "#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/r/key.key\",IV=0x1\n", - "#EXT-X-MAP:URI=\"/hls/shared/live/proxy-id/map/000000.mp4\",BYTERANGE=\"10@0\"\n", - "#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/r/part.m4s\"\n", - "#EXT-X-MEDIA-SEQUENCE:7\n", - "#EXTINF:4.0,\n", - "/hls/shared/live/proxy-id/000007.ts\n", - "https://origin.example.com/not-proxy.ts\n", - ); - - let prefixed = super::apply_hls_proxy_public_path_prefix(body.to_string(), Some("/iptv/")); - - assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/r/key.key\"")); - assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/map/000000.mp4\"")); - assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/r/part.m4s\"")); - assert!(prefixed.contains("\n/iptv/hls/shared/live/proxy-id/000007.ts\n")); - assert!(prefixed.contains("#EXT-X-MEDIA-SEQUENCE:7")); - assert!(prefixed.contains("https://origin.example.com/not-proxy.ts")); - } - - #[test] - fn hls_proxy_public_path_prefix_keeps_body_unchanged_without_server_path() { - let body = "#EXTM3U\n#EXTINF:4.0,\n/hls/shared/live/proxy-id/000007.ts\n".to_string(); - - assert_eq!(super::apply_hls_proxy_public_path_prefix(body.clone(), None), body); - assert_eq!(super::apply_hls_proxy_public_path_prefix(body.clone(), Some("/")), body); - } - - #[test] - fn hls_manifest_materialization_uses_proxy_paths_without_provider_or_legacy_route() { - let body = format!( - "#EXTM3U\n#EXTINF:4.0,\n/hls/shared/live/proxy-id/{}/000123.ts\n", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ); - let lease_id = HlsAccessLeaseId("access-lease".to_string()); - - let materialized = super::materialize_hls_access_manifest(&body, &lease_id, Some("/iptv")); - - assert!(materialized.contains("/iptv/hls/shared/live/proxy-id/access-lease/000123.ts")); - assert!(!materialized.contains("provider://")); - assert!(!materialized.contains("/hls/hls-user/")); - assert!(!materialized.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - } - - #[test] - fn hls_cache_origin_entry_url_preserves_provider_scheme_as_failover() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "demo".into(), - urls: vec!["http://origin.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input = ConfigInput { provider_configs: Some(vec![Arc::clone(&provider)]), ..ConfigInput::default() }; - - let origin = - super::resolve_hls_cache_origin_entry_url(&input, "provider://demo/live/account-a/token-a/1025130.m3u8") - .expect("provider entry url should resolve"); - - assert_eq!(origin.session_entry_url.as_str(), "provider://demo/live/account-a/token-a/1025130.m3u8"); - assert_eq!( - origin.session_entry_url.url_failover_provider().expect("provider failover config").name.as_ref(), - "demo" - ); - let provider_key = super::build_hls_origin_source(&input, "1025130").session_key(); - let direct_key = super::build_hls_origin_source(&input, "1025130").session_key(); - assert_eq!(provider_key, direct_key); - assert_eq!(provider_key.stable_value(), "input:0|hls|1025130"); - assert!(!provider_key.stable_value().contains("provider://")); - assert!(!provider_key.stable_value().contains("origin.example.com")); - } - - #[test] - fn hls_cache_origin_entry_url_does_not_attach_url_failover_provider_to_http_url() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "demo".into(), - urls: vec!["http://origin.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input = ConfigInput { provider_configs: Some(vec![provider]), ..ConfigInput::default() }; - - let origin = super::resolve_hls_cache_origin_entry_url( - &input, - "http://origin.example.com/live/account-a/token-a/1025130.m3u8", - ) - .expect("http entry url should resolve"); - - assert_eq!(origin.session_entry_url.as_str(), "http://origin.example.com/live/account-a/token-a/1025130.m3u8"); - assert!(origin.session_entry_url.url_failover_provider().is_none()); - } - - #[test] - fn hls_origin_source_kind_covers_xtream_m3u_and_direct_media_playlist() { - assert_eq!(super::hls_origin_source_kind(InputType::Xtream), HlsOriginSourceKind::XtreamLive); - assert_eq!(super::hls_origin_source_kind(InputType::M3u), HlsOriginSourceKind::M3uMediaPlaylist); - assert_eq!(super::hls_origin_source_kind(InputType::Library), HlsOriginSourceKind::DirectMediaPlaylist); - } - - #[test] - fn hls_manifest_extension_helper_does_not_create_double_dot_urls() { - assert_eq!( - super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123.m3u8"), - "http://origin.example.com/live/user/pass/1025123.m3u8" - ); - assert_eq!( - super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123..m3u8"), - "http://origin.example.com/live/user/pass/1025123.m3u8" - ); - assert_eq!( - super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123..?token=1"), - "http://origin.example.com/live/user/pass/1025123.m3u8?token=1" - ); - assert_eq!( - super::ensure_hls_manifest_extension("provider://mirror/live/user/pass/1025123..m3u8"), - "provider://mirror/live/user/pass/1025123.m3u8" - ); - } - - #[test] - fn hls_origin_resolution_preserves_legacy_built_xtream_origin_url() { - let input = ConfigInput { - id: 7, - name: Arc::from("xtream"), - input_type: InputType::Xtream, - url: "http://origin.example.com/base".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - ..ConfigInput::default() - }; - - let origin = - super::build_hls_origin_resolution(&input, "http://other.example.com/live/other/creds/1025126.m3u8") - .expect("xtream origin should resolve"); - - assert_eq!(origin.session_entry_url.as_str(), "http://other.example.com/live/other/creds/1025126.m3u8"); - assert_eq!(origin.hls_url, origin.session_entry_url.as_str()); - assert!(origin.session_entry_url.url_failover_provider().is_none()); - } - - #[test] - fn hls_origin_resolution_keeps_provider_failover_out_of_identity() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "mirror-group".into(), - urls: vec!["http://mirror-a.example.com".into(), "http://mirror-b.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input = ConfigInput { - id: 7, - name: Arc::from("xtream"), - input_type: InputType::Xtream, - url: "provider://mirror-group".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - provider_configs: Some(vec![Arc::clone(&provider)]), - ..ConfigInput::default() - }; - - let origin = super::build_hls_origin_resolution( - &input, - "provider://mirror-group/live/source-user/source-pass/1025126.m3u8", - ) - .expect("provider failover origin should resolve"); - let failover_key = super::build_hls_origin_source(&input, "80510").session_key(); - let direct_key = super::build_hls_origin_source(&input, "80510").session_key(); - - assert_eq!( - origin.session_entry_url.as_str(), - "provider://mirror-group/live/source-user/source-pass/1025126.m3u8" - ); - assert!(origin.session_entry_url.url_failover_provider().is_some()); - assert_eq!(failover_key, direct_key); - assert!(!failover_key.stable_value().contains("provider://")); - assert!(!failover_key.stable_value().contains("mirror-a.example.com")); - assert!(!failover_key.stable_value().contains("mirror-b.example.com")); - } - - #[test] - fn hls_origin_resolution_uses_m3u_playlist_item_url() { - let input = ConfigInput { - id: 9, - name: Arc::from("m3u"), - input_type: InputType::M3u, - url: "http://playlist.example.com/list.m3u".to_string(), - ..ConfigInput::default() - }; - - let origin = super::build_hls_origin_resolution(&input, "http://media.example.com/live/channel/index.m3u8") - .expect("m3u hls origin should resolve"); - let source = super::build_hls_origin_source(&input, "stable-item"); - - assert_eq!(origin.session_entry_url.as_str(), "http://media.example.com/live/channel/index.m3u8"); - assert_eq!(source.source_kind, HlsOriginSourceKind::M3uMediaPlaylist); - assert_eq!(source.session_key().stable_value(), "input:9|hls|stable-item"); - } - - #[test] - fn provider_failover_mirror_change_keeps_same_hls_session_identity() { - let provider_a = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "mirror-group".into(), - urls: vec!["http://mirror-a.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let provider_b = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "mirror-group".into(), - urls: vec!["http://mirror-b.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input_a = ConfigInput { - id: 7, - name: Arc::from("xtream"), - input_type: InputType::Xtream, - url: "provider://mirror-group".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - provider_configs: Some(vec![provider_a]), - ..ConfigInput::default() - }; - let input_b = ConfigInput { provider_configs: Some(vec![provider_b]), ..input_a.clone() }; - - let origin_a = super::build_hls_origin_resolution(&input_a, "provider://mirror-group/a.m3u8") - .expect("provider failover origin a should resolve"); - let origin_b = super::build_hls_origin_resolution(&input_b, "provider://mirror-group/b.m3u8") - .expect("provider failover origin b should resolve"); - let key_a = super::build_hls_origin_source(&input_a, "80510").session_key(); - let key_b = super::build_hls_origin_source(&input_b, "80510").session_key(); - let secret = b"rewrite-secret"; - - assert!(origin_a.session_entry_url.url_failover_provider().is_some()); - assert!(origin_b.session_entry_url.url_failover_provider().is_some()); - assert_eq!(key_a, key_b); - assert_eq!(key_a.stable_value(), "input:7|hls|80510"); - assert_eq!(build_proxy_session_id(&key_a, secret), build_proxy_session_id(&key_b, secret)); - assert!(!key_a.stable_value().contains("provider://")); - assert!(!key_a.stable_value().contains("mirror-a.example.com")); - assert!(!key_a.stable_value().contains("mirror-b.example.com")); - } - - #[test] - fn hls_runtime_origin_fetch_url_uses_selected_provider_account() { - let input = ConfigInput { - name: Arc::from("source"), - url: "http://source.example.com".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::Xtream, - ..ConfigInput::default() - }; - let provider_input = ConfigInput { - id: 7, - name: Arc::from("selected-provider"), - url: "http://provider.example.com".to_string(), - username: Some("provider-user".to_string()), - password: Some("provider-pass".to_string()), - input_type: InputType::Xtream, - max_connections: 1, - ..ConfigInput::default() - }; - let provider = Arc::new(RuntimeProviderConfig::new( - &provider_input, - Arc::new(tokio::sync::RwLock::new(ProviderConfigConnection::default())), - Arc::new(|_, _| {}), - )); - - let fetch_url = super::build_hls_origin_fetch_url( - &input, - "http://source.example.com/live/source-user/source-pass/12345.m3u8", - "http://source.example.com/live/source-user/source-pass/12345.m3u8", - Some(&provider), - ) - .expect("fetch url should be rewritten"); - - assert_eq!(fetch_url, "http://provider.example.com/live/provider-user/provider-pass/12345.m3u8"); - - let failover_provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "demo".into(), - urls: vec!["http://mirror-a.example.com".into(), "http://mirror-b.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let provider_scheme_input = ConfigInput { - name: Arc::from("source"), - url: "provider://demo".to_string(), - username: Some("source-user".to_string()), - password: Some("source-pass".to_string()), - input_type: InputType::Xtream, - provider_configs: Some(vec![Arc::clone(&failover_provider)]), - ..ConfigInput::default() - }; - let provider_scheme_without_account_rewrite = super::build_hls_origin_fetch_url( - &provider_scheme_input, - "provider://demo/live/source-user/source-pass/12345.m3u8", - "provider://demo/live/source-user/source-pass/12345.m3u8", - None, - ) - .expect("provider scheme fetch url should remain failover capable"); - assert_eq!(provider_scheme_without_account_rewrite, "provider://demo/live/source-user/source-pass/12345.m3u8"); - - let provider_scheme_fetch_url = super::build_hls_origin_fetch_url( - &provider_scheme_input, - "provider://demo/live/source-user/source-pass/12345.m3u8", - "provider://demo/live/source-user/source-pass/12345.m3u8", - Some(&provider), - ) - .expect("provider scheme fetch url should use selected runtime account without losing failover"); - - assert_eq!(provider_scheme_fetch_url, "provider://demo/live/provider-user/provider-pass/12345.m3u8"); - let failover_context = super::hls_url_failover_provider_for_origin_context( - &provider_scheme_input, - "provider://demo/live/source-user/source-pass/12345.m3u8", - "provider://demo/live/source-user/source-pass/12345.m3u8", - &provider_scheme_fetch_url, - ) - .expect("provider failover context"); - assert_eq!(failover_context.name.as_ref(), "demo"); - } - - #[test] - fn hls_origin_entry_attaches_url_failover_provider_only_to_provider_scheme_fetch_url() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "demo".into(), - urls: vec!["http://mirror.example.com".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - - let http_provider = super::effective_hls_url_failover_provider_for_fetch_url( - "http://provider.example.com/live/user/pass/12345.m3u8", - None, - Some(Arc::clone(&provider)), - ); - let http_entry = super::LiveHlsOriginEntry::parse_with_url_failover_provider( - "http://provider.example.com/live/user/pass/12345.m3u8", - http_provider, - ) - .expect("http origin entry"); - assert!(http_entry.url_failover_provider().is_none()); - - let provider_scheme_provider = super::effective_hls_url_failover_provider_for_fetch_url( - "provider://demo/live/user/pass/12345.m3u8", - None, - Some(Arc::clone(&provider)), - ); - let provider_entry = super::LiveHlsOriginEntry::parse_with_url_failover_provider( - "provider://demo/live/user/pass/12345.m3u8", - provider_scheme_provider, - ) - .expect("provider origin entry"); - assert_eq!(provider_entry.url_failover_provider().expect("provider").name.as_ref(), "demo"); - } - - #[tokio::test] - async fn hls_access_lease_validity_uses_session_idle_timeout_not_cache_duration() { - let hls_dto = HlsCacheConfigDto { - cache_duration: shared::model::Secs::new(900), - session_idle_timeout: shared::model::Secs::new(42), - ..Default::default() - }; - let hls_config = HlsCacheConfig::from(&hls_dto); - let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); - - assert_eq!(super::hls_access_lease_ttl_ms(&app_state), 42_000); - } - - #[tokio::test] - async fn hls_access_lease_active_window_uses_two_target_durations() { - let app_state = test_app_state(); - let key = HlsSessionKey::new(1, "access-window-stream"); - let (session, _) = - app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", super::current_time_millis()).await; - session.write().await.target_duration = Some(11); - - let timing = super::hls_access_lease_timing_for_session(&app_state, &session).await; - - assert_eq!(timing.active_window_ms, 22_000); - assert_eq!(timing.valid_window_ms, super::hls_access_lease_ttl_ms(&app_state)); - } - - #[tokio::test] - async fn hls_lifecycle_active_timer_moves_access_lease_to_idle() { - let app_state = test_app_state(); - let now_ms = super::current_time_millis(); - let lease_id = HlsAccessLeaseId("lifecycle-lease".to_string()); - let key = HlsSessionKey::new(1, "lifecycle-stream"); - let (session, _) = app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "lifecycle-stream".to_string(), - 123, - now_ms, - 60_000, - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 1, valid_window_ms: 60_000 }, - ) - .await - .is_activated()); - session.write().await.activity.active_access_lease_count = 1; - - app_state - .hls_proxy - .handle_lifecycle_event( - &app_state.active_users, - &app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::AccessLeaseActive { - lease_id: lease_id.clone(), - proxy_session_id: proxy_session_id.clone(), - }, - due_at_ms: now_ms.saturating_add(1), - }, - now_ms.saturating_add(2), - ) - .await; - - assert_eq!( - app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), - Some(HlsAccessLeaseState::Idle) - ); - assert_eq!(session.read().await.activity.active_access_lease_count, 0); - } - - #[tokio::test] - async fn hls_lifecycle_validity_timer_removes_expired_access_lease() { - let mut hls_cache = HlsCacheConfigDto::default(); - hls_cache.segment_repair.max_level = HlsSegmentRepairMode::Low; - let hls_config = HlsCacheConfig::from(&hls_cache); - let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); - let now_ms = super::current_time_millis(); - let proxy_session_id = ProxySessionId("lifecycle-validity-proxy".to_string()); - let lease_id = HlsAccessLeaseId("lifecycle-validity-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "lifecycle-stream".to_string(), - 123, - now_ms, - 1, - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 1, valid_window_ms: 1 }, - ) - .await - .is_activated()); - let repair_before = app_state.hls_proxy.segment_repair().stats().await; - assert_eq!(repair_before.windows, 1); - assert_eq!(repair_before.generations, 1); - - app_state - .hls_proxy - .handle_lifecycle_event( - &app_state.active_users, - &app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::AccessLeaseValidity { - lease_id: lease_id.clone(), - proxy_session_id: proxy_session_id.clone(), - }, - due_at_ms: now_ms.saturating_add(1), - }, - now_ms.saturating_add(2), - ) - .await; - - assert_eq!( - app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), - None - ); - let repair_after = app_state.hls_proxy.segment_repair().stats().await; - assert_eq!(repair_after.windows, 0); - assert_eq!(repair_after.generations, 0); - } - - #[tokio::test] - async fn hls_lifecycle_validity_timer_removes_expired_pending_access_lease() { - let app_state = test_app_state(); - let now_ms = super::current_time_millis(); - let key = HlsSessionKey::new(1, "pending-expiry-stream"); - let (session, _) = app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let lease_id = HlsAccessLeaseId("pending-expiry-lease".to_string()); - let active_lease_id = HlsAccessLeaseId("active-soft-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "pending-expiry-stream".to_string(), - 123, - now_ms, - 1, - )) - .await; - app_state - .hls_proxy - .prepare_access_lease( - HlsAccessLease::pending( - active_lease_id.clone(), - HlsPlaybackFamilyKey::new("soft-user", "client"), - proxy_session_id.clone(), - "soft-user".to_string(), - "soft-session-token".to_string(), - 1, - "pending-expiry-stream".to_string(), - 123, - now_ms, - 60_000, - ) - .with_origin_acquire_policy(ConnectionKind::Soft, 20), - ) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &active_lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 60_000, valid_window_ms: 60_000 }, - ) - .await - .is_activated()); - - app_state - .hls_proxy - .handle_lifecycle_event( - &app_state.active_users, - &app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::AccessLeaseValidity { - lease_id: lease_id.clone(), - proxy_session_id: proxy_session_id.clone(), - }, - due_at_ms: now_ms.saturating_add(1), - }, - now_ms.saturating_add(2), - ) - .await; - - assert_eq!( - app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), - None - ); - let session = session.read().await; - assert_eq!(session.activity.active_access_lease_count, 1); - let effective_policy = session.effective_origin_acquire_policy_or_default(); - assert_eq!(effective_policy.connection_kind, ConnectionKind::Soft); - assert_eq!(effective_policy.priority, 20); - } - - #[tokio::test] - async fn hls_lifecycle_session_idle_timer_removes_idle_session() { - let mut hls_dto = HlsCacheConfigDto { session_idle_timeout: shared::model::Secs::new(1), ..Default::default() }; - hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; - let hls_config = HlsCacheConfig::from(&hls_dto); - let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); - let now_ms = super::current_time_millis(); - let key = HlsSessionKey::new(1, "expired-session"); - let (session, _) = - app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms.saturating_sub(2_000)).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let lease_id = HlsAccessLeaseId("session-idle-cleanup-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "expired-session".to_string(), - 123, - now_ms, - 60_000, - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 30_000, valid_window_ms: 60_000 }, - ) - .await - .is_activated()); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); - assert_eq!(app_state.hls_proxy.segment_repair().stats().await.windows, 1); - - app_state - .hls_proxy - .handle_lifecycle_event( - &app_state.active_users, - &app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::SessionIdle { proxy_session_id: proxy_session_id.clone() }, - due_at_ms: now_ms.saturating_sub(1_000), - }, - now_ms, - ) - .await; - - assert!(app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.is_none()); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 0); - let repair_after = app_state.hls_proxy.segment_repair().stats().await; - assert_eq!(repair_after.windows, 0); - assert_eq!(repair_after.generations, 0); - } - - #[tokio::test] - async fn hls_gc_session_removal_cleans_access_leases_and_repair_state() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let mut hls_dto = HlsCacheConfigDto { - cache_path: Some(temp_dir.path().to_string_lossy().into_owned()), - session_idle_timeout: shared::model::Secs::new(1), - ..Default::default() - }; - hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; - let hls_config = HlsCacheConfig::from(&hls_dto); - let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); - let now_ms = super::current_time_millis(); - let key = HlsSessionKey::new(1, "gc-cleanup-session"); - let (session, _) = - app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms.saturating_sub(2_000)).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let lease_id = HlsAccessLeaseId("gc-cleanup-lease".to_string()); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "gc-cleanup-session".to_string(), - 123, - now_ms, - 60_000, - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: 30_000, valid_window_ms: 60_000 }, - ) - .await - .is_activated()); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); - assert_eq!(app_state.hls_proxy.segment_repair().stats().await.windows, 1); - - let report = app_state.hls_proxy.run_garbage_collection_once(now_ms).await.expect("gc should run"); - - assert_eq!(report.sessions_deleted, 1); - assert!(app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.is_none()); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 0); - let repair_after = app_state.hls_proxy.segment_repair().stats().await; - assert_eq!(repair_after.windows, 0); - assert_eq!(repair_after.generations, 0); - } - - fn test_app_state() -> Arc { test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::new())) } - - #[tokio::test] - async fn lease_snapshot_limit_rejection_is_controlled_and_observable() { - let app_state = test_app_state(); - let proxy_session_id = ProxySessionId("snapshot-limit-session".to_string()); - let access_lease_id = HlsAccessLeaseId("snapshot-limit-lease".to_string()); - let oversized_uri = "x".repeat(MAX_HLS_MANIFEST_BYTES + 1); - let body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\n{oversized_uri}\n"); - let derivation = super::derive_hls_lease_manifest_snapshot( - &super::HlsLeaseManifestSnapshotInput::TransientPassthrough { - materialized_body: &body, - source_commit_identity: HlsManifestCommitIdentity::committed(1, 1), - finalized_manifest_generation: None, - }, - 2, - ); - - assert!(super::observe_hls_lease_manifest_snapshot_derivation( - &app_state, - &proxy_session_id, - &access_lease_id, - derivation, - ) - .is_err()); - assert_eq!(app_state.hls_proxy.metrics().snapshot().manifest_limit_rejections, 1); - } - - fn test_beast_hls_proxy(cache_path: &std::path::Path) -> Arc { - let config = HlsCacheConfig::from(&HlsCacheConfigDto { - cache_path: Some(cache_path.to_string_lossy().into_owned()), - strip: HlsStripConfigDto { mode: HlsStripMode::Segments, value: 3 }, - max_segments_prefetch: 6, - manifest_recovery_burst: HlsManifestRecoveryBurstConfigDto { level: HlsManifestRecoveryBurstLevel::Beast }, - ..HlsCacheConfigDto::default() - }); - Arc::new(HlsProxyManager::from_hls_cache_config(Some(&config))) - } - - fn disable_custom_stream_response(app_state: &Arc) { - app_state - .app_config - .config - .store(Arc::new(Config { custom_stream_response_enabled: false, ..Default::default() })); - } - - fn enable_provider_exhausted_custom_response(app_state: &Arc) { - app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { - channel_unavailable: None, - user_connections_exhausted: None, - provider_connections_exhausted: Some(test_custom_video_buffer()), - low_priority_preempted: None, - user_account_expired: None, - panel_api_provisioning: None, - hls_session_or_lease_expired: None, - panel_api_provisioning_hls_segments: Vec::new(), - }))); - } - - fn enable_runtime_policy_custom_responses(app_state: &Arc) { - app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { - channel_unavailable: None, - user_connections_exhausted: Some(TransportStreamBuffer::new( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../test/fixtures/hls/user_connections_exhausted.ts" - )) - .to_vec(), - )), - provider_connections_exhausted: Some(TransportStreamBuffer::new( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../test/fixtures/hls/provider_connections_exhausted.ts" - )) - .to_vec(), - )), - low_priority_preempted: Some(TransportStreamBuffer::new( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../test/fixtures/hls/low_priority_preempted.ts" - )) - .to_vec(), - )), - user_account_expired: None, - panel_api_provisioning: None, - hls_session_or_lease_expired: None, - panel_api_provisioning_hls_segments: Vec::new(), - }))); - } - - fn enable_channel_unavailable_custom_response(app_state: &Arc) { - let config = app_state.app_config.config.load(); - app_state - .app_config - .config - .store(Arc::new(Config { custom_stream_response_enabled: true, ..config.as_ref().clone() })); - app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { - channel_unavailable: Some(test_custom_video_buffer()), - user_connections_exhausted: None, - provider_connections_exhausted: None, - low_priority_preempted: None, - user_account_expired: None, - panel_api_provisioning: None, - hls_session_or_lease_expired: None, - panel_api_provisioning_hls_segments: Vec::new(), - }))); - } - - fn test_custom_video_buffer() -> TransportStreamBuffer { - TransportStreamBuffer::new( - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) - .to_vec(), - ) - } - - fn enable_hls_provisioning_custom_response(app_state: &Arc) { - let mut ts_packet = vec![0_u8; 188]; - ts_packet[0] = 0x47; - let provisioning_segments = (0..6) - .map(|index| { - let mut packet = ts_packet.clone(); - packet[1] = u8::try_from(index).expect("test index fits"); - TransportStreamBuffer::new(packet) - }) - .collect(); - app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { - channel_unavailable: None, - user_connections_exhausted: None, - provider_connections_exhausted: None, - low_priority_preempted: None, - user_account_expired: None, - panel_api_provisioning: None, - hls_session_or_lease_expired: None, - panel_api_provisioning_hls_segments: provisioning_segments, - }))); - } - - fn test_app_state_with_inputs(inputs: Vec>) -> Arc { - test_app_state_with_hls_proxy_and_inputs(Arc::new(HlsProxyManager::new()), inputs) - } - - fn enable_hls_cache(app_state: &Arc) { - let config = Config { - reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { - hls_cache: Some(HlsCacheConfigDto::default()), - ..Default::default() - })), - ..Default::default() - }; - app_state.app_config.config.store(Arc::new(config)); - } - - fn test_app_state_with_hls_proxy(hls_proxy: Arc) -> Arc { - test_app_state_with_hls_proxy_and_inputs(hls_proxy, Vec::new()) - } - - fn test_app_state_with_hls_proxy_and_inputs( - hls_proxy: Arc, - inputs: Vec>, - ) -> Arc { - let app_config = test_app_config(); - if !inputs.is_empty() { - app_config.sources.store(Arc::new(SourcesConfig { - batch_files: vec![], - provider: vec![], - group_lookup: build_group_lookup(&inputs), - inputs, - sources: vec![], - templates: None, - })); - } - let event_manager = Arc::new(EventManager::new()); - let active_provider = Arc::new(ActiveProviderManager::new(&app_config, &event_manager)); - let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); - active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); - - let geoip = Arc::new(ArcSwapOption::::default()); - let config = app_config.config.load(); - let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - let connection_manager = Arc::new(ConnectionManager::new( - &active_users, - &active_provider, - &shared_stream_manager, - &event_manager, - None, - )); - let cancel_tokens = CancelTokens::default(); - let metadata_manager = Arc::new(MetadataUpdateManager::new(cancel_tokens.metadata.clone())); - let (manual_update_sender, _) = mpsc::channel::(1); - - Arc::new(AppState { - forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { - enabled: false, - inputs: Vec::new(), - targets: Vec::new(), - target_names: Vec::new(), - })), - app_config, - http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - http_client_no_redirect: Arc::new(ArcSwap::from_pointee( - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("no-redirect client builds"), - )), - public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - downloads: Arc::new(crate::api::model::DownloadQueue::new()), - cache: Arc::new(ArcSwapOption::default()), - shared_stream_manager, - hls_proxy, - hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), - active_users, - active_provider, - connection_manager, - event_manager, - cancel_tokens: Arc::new(ArcSwap::from_pointee(cancel_tokens)), - playlists: Arc::new(PlaylistStorageState::new()), - geoip, - update_guard: UpdateGuard::new(), - metadata_manager, - identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( - std::path::PathBuf::new(), - )), - login_throttle: Arc::new(crate::auth::LoginThrottle::new()), - token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( - std::path::PathBuf::new(), - )), - manual_update_sender, - }) - } - - async fn create_bound_hls_test_session( - app_state: &Arc, - input: &ConfigInput, - stream_ref: &str, - account_name: &str, - now_ms: u64, - ) -> HlsSessionHandle { - let origin_source = super::build_hls_origin_source(input, stream_ref); - let session_key = origin_source.session_key(); - let (session, _) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key, - origin_source, - &app_state.get_encrypt_secret(), - now_ms, - ) - .await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - { - let mut session_guard = session.write().await; - session_guard.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from(account_name), - &proxy_session_id, - now_ms, - )); - } - session - } - - async fn create_unbound_hls_test_session( - app_state: &Arc, - input: &ConfigInput, - stream_ref: &str, - now_ms: u64, - ) -> HlsSessionHandle { - let origin_source = super::build_hls_origin_source(input, stream_ref); - let session_key = origin_source.session_key(); - app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key, - origin_source, - &app_state.get_encrypt_secret(), - now_ms, - ) - .await - .0 - } - - fn test_hls_origin_io_context(app_state: &Arc) -> HlsOriginIoContext { - HlsOriginIoContext { - ctx: app_state.hls_ctx(), - client_addr: test_fingerprint().addr, - allow_grace: false, - priority: 0, - connection_kind: ConnectionKind::Normal, - reservation_ttl_secs: 60, - preacquired_provider_handle: None, - started_generation: None, - } - } - - fn single_hls_provider_input(name: &str) -> ConfigInput { - ConfigInput { - id: 1, - name: Arc::from(name), - input_type: InputType::Xtream, - url: "http://account.example.com".to_string(), - username: Some("account-user".to_string()), - password: Some("account-pass".to_string()), - enabled: true, - priority: 0, - max_connections: 1, - ..ConfigInput::default() - } - } - - fn overlap_provider_input() -> ConfigInput { - ConfigInput { - id: 1, - name: Arc::from("overlap-input"), - input_type: InputType::Xtream, - url: "http://root.example.com".to_string(), - username: Some("root-user".to_string()), - password: Some("root-pass".to_string()), - enabled: true, - priority: 10, - max_connections: 1, - aliases: Some(vec![crate::model::ConfigInputAlias { - id: 2, - name: Arc::from("account-a"), - url: "http://account.example.com".to_string(), - username: Some("account-user".to_string()), - password: Some("account-pass".to_string()), - priority: 0, - max_connections: 1, - exp_date: None, - enabled: true, - stalker: None, - }]), - ..ConfigInput::default() - } - } - - #[tokio::test] - async fn hls_origin_account_io_lease_allows_parallel_same_session_origin_work() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let binding = session.read().await.origin_account_binding.clone().expect("binding exists"); - let origin_io = test_hls_origin_io_context(&app_state); - - let first = begin_hls_origin_account_io(&origin_io, &session, &binding) - .await - .expect("first same-session origin io acquires provider account"); - wait_for_provider_connection_count(&app_state, 1).await; - let second = begin_hls_origin_account_io(&origin_io, &session, &binding) - .await - .expect("second same-session origin io joins session lease"); - wait_for_provider_connection_count(&app_state, 1).await; - assert_eq!( - session - .read() - .await - .origin_account_io_lease - .as_ref() - .expect("session provider lease exists") - .active_io_count, - 2 - ); - - finish_hls_origin_account_io(&origin_io, &session, first, true).await; - wait_for_provider_connection_count(&app_state, 1).await; - assert_eq!( - session - .read() - .await - .origin_account_io_lease - .as_ref() - .expect("session provider lease remains while second io is active") - .active_io_count, - 1 - ); - - finish_hls_origin_account_io(&origin_io, &session, second, true).await; - wait_for_provider_connection_count(&app_state, 0).await; - assert!( - app_state - .active_provider - .is_provider_reserved_for_other_session(&binding.account_name, Some("other-hls-session")) - .await - ); - } - - #[tokio::test] - async fn hls_origin_account_io_lease_blocks_other_hls_sessions_for_same_account() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let first_session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let second_session = create_bound_hls_test_session(&app_state, &input, "67890", "account-a", 1_000).await; - let first_binding = first_session.read().await.origin_account_binding.clone().expect("binding exists"); - let second_binding = second_session.read().await.origin_account_binding.clone().expect("binding exists"); - let origin_io = test_hls_origin_io_context(&app_state); - - let first = begin_hls_origin_account_io(&origin_io, &first_session, &first_binding) - .await - .expect("first session acquires account"); - wait_for_provider_connection_count(&app_state, 1).await; - - assert!(begin_hls_origin_account_io(&origin_io, &second_session, &second_binding).await.is_err()); - wait_for_provider_connection_count(&app_state, 1).await; - - finish_hls_origin_account_io(&origin_io, &first_session, first, true).await; - wait_for_provider_connection_count(&app_state, 0).await; - } - - #[tokio::test] - async fn transient_origin_binding_requires_runtime_prepare_for_missing_or_detached_account() { - let input = single_hls_provider_input("known-account"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let proxy_session_id = ProxySessionId("shared-hls-session".to_string()); - let known_binding = - HlsOriginAccountBinding::new(Arc::clone(&input.name), Arc::clone(&input.name), &proxy_session_id, 1_000); - let missing_binding = HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from("removed-account"), - &proxy_session_id, - 1_000, - ); - let mut detached_binding = known_binding.clone(); - detached_binding.detach(HlsOriginAccountDetachedReason::AccountMissingOrExpired, 2_000); - - let hls_ctx = app_state.hls_ctx(); - assert!(!super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &known_binding)); - assert!(super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &missing_binding)); - assert!(super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &detached_binding)); - } - - #[tokio::test] - async fn hls_account_overlap_selects_soft_candidate_but_not_hard_active() { - let app_state = test_app_state(); - let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; - let session = create_bound_hls_test_session(&app_state, &input, "old", "account-a", 1_000).await; - { - let mut session = session.write().await; - session.target_duration = Some(10); - session.mark_authorized_media_access(1_000); - } - let new_proxy_session_id = ProxySessionId("new-session".to_string()); - - let hard_candidate = - super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 5_000).await; - assert!(hard_candidate.is_none(), "hard-active sessions must not be overbooked"); - - let delayed_candidate = - super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 12_000).await; - assert!(delayed_candidate.is_none(), "soft-active candidate must respect the dynamic overlap delay"); - - let soft_candidate = - super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 21_000) - .await - .expect("soft-active session can be overbooked"); - assert_eq!(soft_candidate.account_name.as_ref(), "account-a"); - assert_eq!(soft_candidate.last_media_at_ms, 1_000); - assert_eq!(soft_candidate.soft_overlap_eligible_at_ms, 21_000); - assert_eq!(soft_candidate.soft_overlap_delay_ms, 20_000); - assert_eq!(soft_candidate.reclaim_until_ms, 31_000); - } - - #[test] - fn hls_soft_overlap_delay_scales_with_tuliprox_target_pressure() { - assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 1, 1), 20_000); - assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 3, 2), 15_000); - assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 4, 2), 10_000); - } - - #[tokio::test] - async fn hls_account_overlap_reclaim_preempts_speculative_session() { - let app_state = test_app_state(); - let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; - let winner = create_bound_hls_test_session(&app_state, &input, "winner", "account-a", 1_000).await; - let loser = create_bound_hls_test_session(&app_state, &input, "loser", "account-a", 1_000).await; - let winner_proxy_session_id = winner.read().await.proxy_session_id.clone(); - let loser_proxy_session_id = loser.read().await.proxy_session_id.clone(); - { - let mut loser = loser.write().await; - loser.origin_account_binding = Some(HlsOriginAccountBinding::speculative_from( - Arc::clone(&input.name), - Arc::from("account-a"), - &loser_proxy_session_id, - winner_proxy_session_id.clone(), - 20_000, - 2_000, - )); - } - let loser_generation = loser.read().await.activity.origin_work_generation; - - super::reclaim_hls_account_overlap_if_needed(&app_state, &winner, 10_000).await; - - assert!( - app_state.hls_proxy.is_account_overlap_cooling_down(&input.name, &Arc::from("account-a"), 10_000).await - ); - assert!( - !app_state.hls_proxy.is_account_overlap_cooling_down(&input.name, &Arc::from("account-a"), 25_000).await - ); - let loser_binding_mode = loser.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); - assert!(matches!( - loser_binding_mode, - HlsOriginAccountBindingMode::Detached { - reason: HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, - .. - } - )); - assert_eq!(loser.read().await.activity.origin_work_generation, loser_generation + 1); - let winner_binding_mode = winner.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); - assert!(matches!(winner_binding_mode, HlsOriginAccountBindingMode::Active)); - } - - #[tokio::test] - async fn hls_account_overlap_promotes_speculative_session_after_soft_window() { - let app_state = test_app_state(); - let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; - let displaced = create_bound_hls_test_session(&app_state, &input, "displaced", "account-a", 1_000).await; - let promoted = create_bound_hls_test_session(&app_state, &input, "promoted", "account-a", 1_000).await; - let displaced_proxy_session_id = displaced.read().await.proxy_session_id.clone(); - let promoted_proxy_session_id = promoted.read().await.proxy_session_id.clone(); - { - let mut promoted = promoted.write().await; - promoted.origin_account_binding = Some(HlsOriginAccountBinding::speculative_from( - Arc::clone(&input.name), - Arc::from("account-a"), - &promoted_proxy_session_id, - displaced_proxy_session_id, - 20_000, - 2_000, - )); - } - let displaced_generation = displaced.read().await.activity.origin_work_generation; - - super::promote_elapsed_hls_account_overlaps(&app_state, 20_001).await; - - let displaced_binding_mode = - displaced.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); - assert!(matches!( - displaced_binding_mode, - HlsOriginAccountBindingMode::Detached { reason: HlsOriginAccountDetachedReason::SoftWindowElapsed, .. } - )); - assert_eq!(displaced.read().await.activity.origin_work_generation, displaced_generation + 1); - let promoted_binding_mode = promoted.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); - assert!(matches!(promoted_binding_mode, HlsOriginAccountBindingMode::Active)); - } - - #[tokio::test] - async fn hls_account_binding_soft_expiry_retains_session_and_reacquires_on_authorized_manifest_work() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - { - let mut session = session.write().await; - session.target_duration = Some(1); - session.mark_authorized_media_access(1_000); - } - let old_generation = session.read().await.activity.origin_work_generation; - - super::detach_unprotected_hls_origin_account_bindings(&app_state, 4_001).await; - - { - let session = session.read().await; - let binding = session.origin_account_binding.as_ref().expect("detached binding is retained"); - assert!(matches!( - binding.binding_mode, - HlsOriginAccountBindingMode::Detached { reason: HlsOriginAccountDetachedReason::SoftWindowElapsed, .. } - )); - assert_eq!(session.activity.origin_work_generation, old_generation + 1); - } - - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let prepared_origin = super::prepare_hls_origin_runtime( - &app_state, - &session, - &input, - "http://root.example.com/live/root-user/root-pass/12345.m3u8", - "http://root.example.com/live/root-user/root-pass/12345.m3u8", - &proxy_session_id, - &test_fingerprint(), - ConnectionKind::Normal, - 0, - super::HlsOriginWorkKind::Manifest, - super::HlsOriginWorkClass::ManifestInteractive, - 4_100, - ) - .await - .expect("authorized origin work can reacquire a detached binding"); - - let binding = - prepared_origin.origin_account_binding_to_store.as_ref().expect("new binding should be stored by caller"); - assert_eq!(binding.account_name.as_ref(), "account-a"); - assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); - assert_eq!(prepared_origin.fetch_url, "http://account.example.com/live/account-user/account-pass/12345.m3u8"); - app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; - } - - #[tokio::test] - async fn hls_origin_runtime_uses_soft_overlap_before_grace_for_interactive_work() { - let input = single_hls_provider_input("soft-overlap-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let old_session = create_bound_hls_test_session(&app_state, &input, "old", input.name.as_ref(), 1_000).await; - let old_proxy_session_id = old_session.read().await.proxy_session_id.clone(); - { - let mut session = old_session.write().await; - session.target_duration = Some(10); - session.mark_authorized_media_access(1_000); - } - let old_binding = old_session.read().await.origin_account_binding.clone().expect("binding exists"); - app_state - .active_provider - .refresh_provider_reservation(&old_binding.account_name, &old_binding.session_owner, 60) - .await; - - let new_session = create_unbound_hls_test_session(&app_state, &input, "new", 12_000).await; - let new_proxy_session_id = new_session.read().await.proxy_session_id.clone(); - let prepared_origin = super::prepare_hls_origin_runtime( - &app_state, - &new_session, - &input, - "http://account.example.com/live/account-user/account-pass/new.m3u8", - "http://account.example.com/live/account-user/account-pass/new.m3u8", - &new_proxy_session_id, - &test_fingerprint_with_addr(test_addr_with_port(55201)), - ConnectionKind::Normal, - 0, - super::HlsOriginWorkKind::Manifest, - super::HlsOriginWorkClass::ManifestInteractive, - 21_000, - ) - .await - .expect("interactive work should use soft-active overlap before grace"); - - let binding = - prepared_origin.origin_account_binding_to_store.as_ref().expect("speculative binding should be prepared"); - assert_eq!(binding.account_name, input.name); - assert!(matches!( - &binding.binding_mode, - HlsOriginAccountBindingMode::Speculative { - displaced_proxy_session_id, - .. - } if displaced_proxy_session_id == &old_proxy_session_id - )); - assert!(matches!( - prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), - Some(super::ProviderAllocation::Available(_)) - )); - - app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; - } - - #[tokio::test] - async fn hls_origin_runtime_normal_policy_preempts_active_soft_hls_binding() { - let input = single_hls_provider_input("policy-preempt-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let soft_session = create_bound_hls_test_session(&app_state, &input, "soft", input.name.as_ref(), 1_000).await; - let soft_generation = { - let mut session = soft_session.write().await; - session.target_duration = Some(10); - session.mark_authorized_media_access(10_000); - session.reconcile_effective_origin_acquire_policy( - Some(HlsEffectiveOriginAcquirePolicy::new(ConnectionKind::Soft, 0, 10_000)), - 10_000, - ); - session.activity.origin_work_generation - }; - let soft_binding = soft_session.read().await.origin_account_binding.clone().expect("soft binding exists"); - app_state - .active_provider - .refresh_provider_reservation(&soft_binding.account_name, &soft_binding.session_owner, 60) - .await; - - let normal_session = create_unbound_hls_test_session(&app_state, &input, "normal", 10_500).await; - let normal_proxy_session_id = normal_session.read().await.proxy_session_id.clone(); - let prepared_origin = super::prepare_hls_origin_runtime( - &app_state, - &normal_session, - &input, - "http://account.example.com/live/account-user/account-pass/normal.m3u8", - "http://account.example.com/live/account-user/account-pass/normal.m3u8", - &normal_proxy_session_id, - &test_fingerprint_with_addr(test_addr_with_port(55231)), - ConnectionKind::Normal, - 0, - super::HlsOriginWorkKind::Manifest, - super::HlsOriginWorkClass::ManifestInteractive, - 10_500, - ) - .await - .expect("normal HLS policy should preempt active soft HLS binding"); - - let new_binding = prepared_origin - .origin_account_binding_to_store - .as_ref() - .expect("preempting session should receive active binding"); - assert_eq!(new_binding.account_name, input.name); - assert!(matches!(new_binding.binding_mode, HlsOriginAccountBindingMode::Active)); - assert!(matches!( - prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), - Some(super::ProviderAllocation::Available(_)) - )); - let soft_session = soft_session.read().await; - assert_eq!(soft_session.activity.origin_work_generation, soft_generation + 1); - assert!(matches!( - soft_session.origin_account_binding.as_ref().map(|binding| &binding.binding_mode), - Some(HlsOriginAccountBindingMode::Detached { - reason: HlsOriginAccountDetachedReason::PreemptedByHigherPriority, - .. - }) - )); - drop(soft_session); - - app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; - } - - #[tokio::test] - async fn preempted_origin_runtime_commits_low_priority_tail_without_redirect_or_origin_fetch() { - let fixture = runtime_policy_endpoint_fixture(true).await; - let input = single_hls_provider_input("runtime-preempted-input"); - let session = fixture - .app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&fixture.proxy_session_id) - .await - .expect("runtime policy session"); - let now_ms = super::current_time_millis(); - let mut binding = HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from("preempted-account"), - &fixture.proxy_session_id, - now_ms, - ); - binding.detach(HlsOriginAccountDetachedReason::PreemptedByHigherPriority, now_ms); - session.write().await.replace_origin_account_binding(Some(binding)); - let origin_refresh_before = session.read().await.origin_refresh.clone(); - let origin = super::HlsCacheManifestOrigin { - raw_request_url: "http://account.example.com/live/account-user/account-pass/12345.m3u8", - session_entry_url: super::HlsOriginEntryUrl::direct_http( - "http://account.example.com/live/account-user/account-pass/12345.m3u8", - ), - input: &input, - origin_source: super::build_hls_origin_source(&input, "12345"), - }; - let context = test_hls_access_context(fixture.proxy_session_id.clone(), fixture.lease_id.clone()); - - let result = super::prepare_hls_canonical_manifest_origin_runtime( - &fixture.app_state, - &session, - &context, - &origin, - &fixture.proxy_session_id, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &test_fingerprint(), - None, - now_ms, - ) - .await; - let Err(response) = result else { - panic!("detached origin binding must resolve to the lease-bound policy tail"); - }; - - assert!(!response.headers().contains_key(header::LOCATION)); - let plan = wait_for_runtime_policy_terminal_plan(&fixture).await; - assert_eq!(plan.reason, HlsRuntimeCustomTailReason::LowPriorityPreempted); - assert_eq!(plan.segment_duration_ms, 10_027); - assert_eq!(session.read().await.origin_refresh, origin_refresh_before); - let replay = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; - assert_eq!(replay.status(), StatusCode::OK); - assert!(!replay.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(replay).await.to_vec()).expect("preemption manifest utf8"); - assert!(body.contains("/terminal/")); - assert!(!body.contains("/cvs/hls/")); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - } - - #[tokio::test] - async fn hls_origin_policy_preemption_rejects_soft_request_against_active_normal_binding() { - let input = single_hls_provider_input("policy-no-preempt-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let normal_session = - create_bound_hls_test_session(&app_state, &input, "normal", input.name.as_ref(), 1_000).await; - { - let mut session = normal_session.write().await; - session.target_duration = Some(10); - session.mark_authorized_media_access(10_000); - session.reconcile_effective_origin_acquire_policy( - Some(HlsEffectiveOriginAcquirePolicy::new(ConnectionKind::Normal, 0, 10_000)), - 10_000, - ); - } - let normal_binding = normal_session.read().await.origin_account_binding.clone().expect("normal binding exists"); - app_state - .active_provider - .refresh_provider_reservation(&normal_binding.account_name, &normal_binding.session_owner, 60) - .await; - let soft_session = create_unbound_hls_test_session(&app_state, &input, "soft", 10_500).await; - let soft_proxy_session_id = soft_session.read().await.proxy_session_id.clone(); - - let result = super::prepare_hls_origin_policy_preempt_runtime( - &app_state, - &soft_session, - &input, - "http://account.example.com/live/account-user/account-pass/soft.m3u8", - "http://account.example.com/live/account-user/account-pass/soft.m3u8", - &soft_proxy_session_id, - &test_fingerprint_with_addr(test_addr_with_port(55232)), - ConnectionKind::Soft, - -100, - 10_500, - ) - .await; - - assert!(result.is_err()); - assert!(matches!( - normal_session.read().await.origin_account_binding.as_ref().map(|binding| &binding.binding_mode), - Some(HlsOriginAccountBindingMode::Active) - )); - assert!( - app_state - .active_provider - .is_provider_reserved_for_other_session(&normal_binding.account_name, Some("unrelated-session")) - .await - ); - } - - #[tokio::test] - async fn hls_origin_runtime_uses_grace_as_interactive_fallback_after_overlap_fails() { - let input = single_hls_provider_input("grace-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let occupied = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &test_addr_with_port(55211), - false, - 0, - ConnectionKind::Normal, - Some("external-owner"), - ) - .await - .expect("test should occupy the only provider account"); - let session = create_unbound_hls_test_session(&app_state, &input, "12345", 2_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - - let prepared_origin = super::prepare_hls_origin_runtime( - &app_state, - &session, - &input, - "http://account.example.com/live/account-user/account-pass/12345.m3u8", - "http://account.example.com/live/account-user/account-pass/12345.m3u8", - &proxy_session_id, - &test_fingerprint_with_addr(test_addr_with_port(55212)), - ConnectionKind::Normal, - 0, - super::HlsOriginWorkKind::Manifest, - super::HlsOriginWorkClass::ManifestInteractive, - 2_000, - ) - .await - .expect("interactive work can use grace when normal acquire and overlap fail"); - - assert!(matches!( - prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), - Some(super::ProviderAllocation::GracePeriod(_)) - )); - assert_eq!( - prepared_origin - .origin_account_binding_to_store - .as_ref() - .expect("grace binding should still bind the selected account") - .account_name - .as_ref(), - input.name.as_ref() - ); - - app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; - app_state.connection_manager.release_provider_handle(Some(occupied)).await; - } - - #[tokio::test] - async fn hls_provider_exhausted_without_provisioning_returns_custom_manifest() { - let input = single_hls_provider_input("provider-exhausted-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - store_test_sources_with_target( - &app_state, - input.clone(), - ConfigTarget::from(&ConfigTargetDto { id: 1, name: "default".to_string(), ..Default::default() }), - ); - enable_provider_exhausted_custom_response(&app_state); - let session = create_unbound_hls_test_session(&app_state, &input, "provider-exhausted-session", 1_000).await; - let access_lease_id = HlsAccessLeaseId("provider-exhausted-lease".to_string()); - let proxy_session_id = session.read().await.proxy_session_id.clone(); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - let strip = app_state.hls_proxy.strip(); - - let response = super::hls_shared_provisioning_or_provider_exhausted_response( - &app_state, - &session, - "hls-user", - &input, - 59, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - let manifest = String::from_utf8(body.to_vec()).expect("manifest is utf8"); - assert!(manifest.contains("#EXTM3U")); - assert!(manifest.contains(&format!("/cvs/hls/{}/", access_lease_id.0))); - assert!(!manifest.contains("/hls-user/")); - assert!(!manifest.contains("/hls-pass/")); - assert!(!manifest.contains("/provider_connections_exhausted/")); - } - - #[tokio::test] - async fn hls_provider_exhausted_grace_hold_waits_for_grace_period_before_retry() { - let input = single_hls_provider_input("provider-grace-hold-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - app_state.app_config.config.store(Arc::new(Config { - reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { - stream: Some(StreamConfigDto { - grace_period_millis: 20, - grace_period_timeout_secs: 10, - grace_period_hold_stream: true, - ..Default::default() - }), - ..Default::default() - })), - ..Default::default() - })); - let session = create_unbound_hls_test_session(&app_state, &input, "provider-grace-session", 1_000).await; - let access_lease_id = HlsAccessLeaseId("provider-grace-lease".to_string()); - let strip = app_state.hls_proxy.strip(); - let resolution = super::hls_provider_connections_exhausted_manifest_resolution( - &app_state, - &session, - "hls-user", - &input, - 59, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - true, - ); - - let resolution = tokio::time::timeout(Duration::from_millis(500), resolution) - .await - .expect("grace hold deadline should wake"); - - assert!(matches!(resolution, super::HlsProviderExhaustedResolution::RetryAcquire)); - } - - #[tokio::test] - async fn provider_grace_expiry_commits_lease_bound_provider_exhausted_tail() { - let fixture = runtime_policy_endpoint_fixture(true).await; - let input = single_hls_provider_input("runtime-provider-grace-input"); - store_test_sources_with_target( - &fixture.app_state, - input.clone(), - ConfigTarget::from(&ConfigTargetDto { id: 1, name: "default".to_string(), ..Default::default() }), - ); - let current = fixture.app_state.app_config.config.load(); - fixture.app_state.app_config.config.store(Arc::new(Config { - reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { - stream: Some(StreamConfigDto { - grace_period_millis: 20, - grace_period_timeout_secs: 10, - grace_period_hold_stream: true, - ..Default::default() - }), - ..Default::default() - })), - ..current.as_ref().clone() - })); - let strip = fixture.app_state.hls_proxy.strip(); - let session = fixture - .app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&fixture.proxy_session_id) - .await - .expect("runtime provider session"); - - let grace = super::hls_provider_connections_exhausted_manifest_resolution( - &fixture.app_state, - &session, - "hls-user", - &input, - 12345, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - true, - ) - .await; - assert!(matches!(grace, super::HlsProviderExhaustedResolution::RetryAcquire)); - - let response = super::hls_provider_connections_exhausted_manifest_resolution( - &fixture.app_state, - &session, - "hls-user", - &input, - 12345, - &fixture.lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - false, - ) - .await; - let super::HlsProviderExhaustedResolution::Response(response) = response else { - panic!("expired grace must resolve to a finite response"); - }; - assert!(!response.headers().contains_key(header::LOCATION)); - - let plan = wait_for_runtime_policy_terminal_plan(&fixture).await; - assert_eq!(plan.reason, HlsRuntimeCustomTailReason::ProviderConnectionsExhausted); - assert_eq!(plan.segment_duration_ms, 10_027); - let replay = super::hls_provider_connections_exhausted_manifest_resolution( - &fixture.app_state, - &session, - "hls-user", - &input, - 12345, - &fixture.lease_id, - HlsAccessLeaseState::Denied, - &strip, - None, - false, - ) - .await; - let super::HlsProviderExhaustedResolution::Response(replay) = replay else { - panic!("committed provider tail must replay"); - }; - assert_eq!(replay.status(), StatusCode::OK); - assert!(!replay.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(replay).await.to_vec()).expect("provider terminal manifest utf8"); - assert!(body.contains("/terminal/")); - assert!(!body.contains("/cvs/hls/")); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - } - - #[tokio::test] - async fn shared_provisioning_timeline_manifest_uses_canonical_hls_session_segments() { - let input = single_hls_provider_input("shared-provisioning-timeline-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_provisioning_custom_response(&app_state); - let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; - let access_lease_id = HlsAccessLeaseId("timeline-lease".to_string()); - let proxy_session_id = session.read().await.proxy_session_id.clone(); - activate_test_hls_access_lease( - &app_state, - &proxy_session_id, - &access_lease_id.0, - super::current_time_millis(), - 60_000, - ) - .await; - let strip = app_state.hls_proxy.strip(); - - let response = super::hls_shared_provisioning_timeline_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - ) - .await - .expect("provisioning manifest should render"); - - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest is utf8"); - assert!(body.contains("#EXT-X-VERSION:7\n")); - assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS\n")); - assert!(body.contains("#EXT-X-TARGETDURATION:2\n")); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); - assert!(body.contains("#EXTINF:2.000,\n")); - assert!(!body.contains("#EXTINF:12.000,\n")); - assert!(body.contains("/hls/shared/live/")); - assert!(body.contains("/000000.ts?pseq=0")); - assert!(body.contains("/000001.ts?pseq=1")); - assert!(body.contains("/000002.ts?pseq=2")); - assert!(body.matches("#EXTINF:").count() <= 6); - assert!(!body.contains("/cvs/hls/")); - { - let session = session.read().await; - assert_eq!(session.proxy_next_seq, Some(3)); - assert_eq!(session.publishable_origin_head_proxy_seq, Some(0)); - assert_eq!(session.publishable_origin_tail_proxy_seq, Some(2)); - assert_eq!(session.segments.len(), 3); - } - - let response = super::hls_shared_provisioning_timeline_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - ) - .await - .expect("subsequent manifest should append one segment"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest is utf8"); - assert!(body.contains("/000003.ts?pseq=3")); - assert!(body.matches("#EXTINF:").count() <= 6); - assert_eq!(session.read().await.proxy_next_seq, Some(4)); - } - - #[tokio::test] - async fn stale_provisioning_segments_do_not_trigger_canonical_handoff() { - let input = single_hls_provider_input("stale-provisioning-handoff-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_provisioning_custom_response(&app_state); - let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; - let initial_lease_id = HlsAccessLeaseId("initial-provisioning-lease".to_string()); - let strip = app_state.hls_proxy.strip(); - - super::hls_shared_provisioning_timeline_manifest_response( - &app_state, - &session, - &initial_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - ) - .await - .expect("provisioning manifest should render local segments"); - { - let session_guard = session.read().await; - assert!(session_guard.segments.values().any(crate::api::model::is_hls_provisioning_segment)); - assert_eq!(session_guard.segments.len(), 3); - assert_eq!(session_guard.pending_handoff_discontinuity_sequence, None); - } - - let new_lease_id = HlsAccessLeaseId("new-playback-lease".to_string()); - let previous_rendered_at = super::maybe_mark_hls_provisioning_handoff_for_canonical_manifest( - &app_state, - &session, - &input, - 12345, - &new_lease_id, - 2_000, - ) - .await; - - assert_eq!(previous_rendered_at, None); - let session_guard = session.read().await; - assert_eq!(session_guard.segments.len(), 3); - assert_eq!(session_guard.pending_handoff_discontinuity_sequence, None); - } - - #[tokio::test] - async fn provisioning_handoff_finds_shared_session_by_input_stream_id_not_virtual_id() { - let input = single_hls_provider_input("origin-id-handoff-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - let origin_session = create_unbound_hls_test_session(&app_state, &input, "80510", 1_000).await; - let virtual_id_session = create_unbound_hls_test_session(&app_state, &input, "1001", 1_000).await; - let stream_identity = super::HlsEntryStreamIdentity::new(1001, "80510").expect("input stream identity"); - - assert!( - super::mark_hls_provisioning_handoff_discontinuity(&app_state, &input, &stream_identity, None, 2_000,) - .await - ); - - assert!(origin_session.read().await.pending_handoff_discontinuity_sequence.is_some()); - assert_eq!(virtual_id_session.read().await.pending_handoff_discontinuity_sequence, None); - } - - #[tokio::test] - async fn shared_provisioning_handoff_continues_proxy_sequence_for_origin_segments() { - let input = single_hls_provider_input("shared-provisioning-handoff-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_provisioning_custom_response(&app_state); - let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; - let access_lease_id = HlsAccessLeaseId("handoff-lease".to_string()); - let strip = app_state.hls_proxy.strip(); - super::hls_shared_provisioning_timeline_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - ) - .await - .expect("provisioning manifest should render"); - - let manifest = match parse_origin_media_manifest( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:4025\n#EXTINF:4.0,\n4025.ts\n#EXTINF:4.0,\n4026.ts\n#EXTINF:4.0,\n4027.ts\n", - "http://origin.example/live/stream.m3u8", - ) { - OriginManifestParseOutcome::Normal(manifest) => manifest, - OriginManifestParseOutcome::TransientPassthrough { reason } => { - panic!("expected normal manifest: {reason:?}") - } - }; - let rendered = { - let mut session_guard = session.write().await; - session_guard.mark_pending_handoff_discontinuity(0); - drop(session_guard); - assert!( - super::ensure_shared_hls_provisioning_handoff_gap(&app_state, &session, 2_000).await, - "handoff should append one gap segment" - ); - let mut session_guard = session.write().await; - session_guard.apply_origin_manifest(&manifest).expect("origin manifest should map"); - for proxy_seq in 4..=6 { - session_guard.segments.get_mut(&proxy_seq).expect("origin segment").status = - SegmentCacheStatus::Ready { content_length: 1024, ready_at_ms: 2_000 }; - } - session_guard.render_and_store_manifest(2_000).expect("handoff manifest should render") - }; - - assert!(rendered.body.contains("#EXT-X-MEDIA-SEQUENCE:1\n")); - assert!(rendered.body.contains("#EXT-X-TARGETDURATION:4\n")); - assert!(rendered.body.contains("/000002.ts?pseq=2")); - assert!(rendered.body.contains("/000004.ts")); - assert!(rendered.body.contains("/000005.ts")); - assert!(rendered.body.contains("/000006.ts")); - assert!(!rendered.body.contains("/004025.ts")); - let provisioning_tail = rendered.body.find("/000002.ts?pseq=2").expect("provisioning tail is rendered"); - let gap_tag = rendered.body.find("#EXT-X-GAP\n").expect("handoff gap tag is rendered"); - let gap_uri = rendered.body.find("/000003.ts?pseq=3").expect("handoff gap uri is rendered"); - let discontinuity = rendered - .body - .find("#EXT-X-DISCONTINUITY\n#EXTINF:4.000,\n/hls/shared/live/") - .expect("origin handoff discontinuity is rendered"); - let first_origin = rendered.body.find("/000004.ts").expect("first origin segment is rendered"); - assert!(provisioning_tail < gap_tag); - assert!(gap_tag < gap_uri); - assert!(gap_uri < discontinuity); - assert!(discontinuity < first_origin); - } - - #[tokio::test] - async fn hls_origin_runtime_background_skips_soft_overlap_and_grace() { - let input = single_hls_provider_input("background-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let old_session = create_bound_hls_test_session(&app_state, &input, "old", input.name.as_ref(), 1_000).await; - { - let mut session = old_session.write().await; - session.target_duration = Some(10); - session.mark_authorized_media_access(1_000); - } - let old_binding = old_session.read().await.origin_account_binding.clone().expect("binding exists"); - app_state - .active_provider - .refresh_provider_reservation(&old_binding.account_name, &old_binding.session_owner, 60) - .await; - let new_session = create_unbound_hls_test_session(&app_state, &input, "new", 12_000).await; - let new_proxy_session_id = new_session.read().await.proxy_session_id.clone(); - - let result = super::prepare_hls_origin_runtime( - &app_state, - &new_session, - &input, - "http://account.example.com/live/account-user/account-pass/new.m3u8", - "http://account.example.com/live/account-user/account-pass/new.m3u8", - &new_proxy_session_id, - &test_fingerprint_with_addr(test_addr_with_port(55221)), - ConnectionKind::Normal, - 0, - super::HlsOriginWorkKind::Segment, - super::HlsOriginWorkClass::Background, - 12_000, - ) - .await; - - assert_eq!( - result.err(), - Some(super::HlsOriginRuntimeAcquireError::NoAccountAvailable { - reason: super::HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted - }) - ); - let old_session = old_session.read().await; - assert!(matches!( - old_session.origin_account_binding.as_ref().expect("old binding remains").binding_mode, - HlsOriginAccountBindingMode::Active - )); - assert!(new_session.read().await.origin_account_binding.is_none()); - } - - #[test] - fn hls_detached_origin_binding_reclaimed_by_owner_maps_to_preempted_no_account_reason() { - let proxy_session_id = ProxySessionId("preempted-session".to_string()); - let mut binding = - HlsOriginAccountBinding::new(Arc::from("input-a"), Arc::from("account-a"), &proxy_session_id, 1_000); - binding.detach(HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, 2_000); - - assert_eq!( - super::hls_no_account_reason_for_binding(Some(&binding)), - super::HlsOriginRuntimeNoAccountReason::OriginBindingPreempted - ); - } - - #[test] - fn hls_detached_origin_binding_soft_window_elapsed_maps_to_exhausted_no_account_reason() { - let proxy_session_id = ProxySessionId("soft-window-session".to_string()); - let mut binding = - HlsOriginAccountBinding::new(Arc::from("input-a"), Arc::from("account-a"), &proxy_session_id, 1_000); - binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, 2_000); - - assert_eq!( - super::hls_no_account_reason_for_binding(Some(&binding)), - super::HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted - ); - } - - #[tokio::test] - async fn hls_account_binding_without_media_activity_is_not_detached() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - { - let mut session = session.write().await; - session.target_duration = Some(12); - session.mark_authorized_manifest_access(1_000); - } - let old_generation = session.read().await.activity.origin_work_generation; - - super::detach_unprotected_hls_origin_account_bindings(&app_state, 60_000).await; - - let session = session.read().await; - let binding = session.origin_account_binding.as_ref().expect("binding remains"); - assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); - assert_eq!(session.activity.origin_work_generation, old_generation); - assert_eq!(session.activity.last_authorized_media_at_ms, None); - assert_eq!(session.account_overlap_timing().target_duration_ms, 12_000); - } - - #[tokio::test] - async fn hls_hard_manifest_failure_forces_next_fresh_commit() { - let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); - { - let mut session = session.write().await; - session.last_rendered_manifest = Some(RenderedManifest { - body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), - first_proxy_seq: 1, - last_proxy_seq: 1, - playlist_duration_ms: 4_000, - valid_until_ms: 5_000, - render_gap_segments: 0, - rendered_at_ms: 1_000, - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![1], - }); - session.require_fresh_manifest_commit(HlsFreshManifestRequiredReason::PreviousHardManifestFailure); - } - - assert_eq!( - super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, 2_000).await, - HlsManifestCommitRequirement::FreshCommitRequired { - reason: HlsFreshManifestRequiredReason::PreviousHardManifestFailure - } - ); - } - - #[tokio::test] - async fn hls_normal_expired_session_allows_committed_manifest_while_manifest_valid() { - let now_ms = 100_000; - let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); - { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.clone(); - session.target_duration = Some(10); - session.mark_authorized_media_access(1_000); - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::from("test-input"), - Arc::from("test-account"), - &proxy_session_id, - now_ms, - )); - session.last_rendered_manifest = Some(RenderedManifest { - body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), - first_proxy_seq: 1, - last_proxy_seq: 1, - playlist_duration_ms: 4_000, - valid_until_ms: now_ms.saturating_add(10_000), - render_gap_segments: 0, - rendered_at_ms: now_ms.saturating_sub(1_000), - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![1], - }); - } - - assert_eq!( - super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, now_ms).await, - HlsManifestCommitRequirement::CommittedManifestAllowed - ); - } - - #[tokio::test] - async fn hls_normal_expired_session_requires_fresh_commit_after_manifest_validity() { - let now_ms = 100_000; - let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); - { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.clone(); - session.target_duration = Some(10); - session.mark_authorized_media_access(1_000); - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::from("test-input"), - Arc::from("test-account"), - &proxy_session_id, - now_ms, - )); - session.last_rendered_manifest = Some(RenderedManifest { - body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), - first_proxy_seq: 1, - last_proxy_seq: 1, - playlist_duration_ms: 4_000, - valid_until_ms: now_ms.saturating_sub(1), - render_gap_segments: 0, - rendered_at_ms: now_ms.saturating_sub(10_000), - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![1], - }); - } - - assert_eq!( - super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, now_ms).await, - HlsManifestCommitRequirement::FreshCommitRequired { - reason: HlsFreshManifestRequiredReason::ExpiredRevalidation - } - ); - } - - #[tokio::test] - async fn hls_ready_cache_hit_does_not_require_origin_reacquire_when_binding_is_detached() { - let app_state = test_app_state(); - let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - { - let mut session = session.write().await; - session - .origin_account_binding - .as_mut() - .expect("binding exists") - .detach(HlsOriginAccountDetachedReason::Cleanup, 2_000); - session.segments.insert( - 123, - SegmentEntry { - origin_key: OriginSegmentKey { - origin_epoch: 0, - effective_host_id: 0, - host_local_sequence: 123, - host_local_index: 123, - }, - proxy_seq: 123, - duration_ms: 4_000, - proxy_file_ext: "ts".to_string(), - content_type: "video/mp2t".to_string(), - cache_key: SegmentCacheKey::new(proxy_session_id, 123, "ts"), - discontinuity_before: false, - program_date_time: None, - daterange_tags_before: Vec::new(), - origin_byte_range: None, - map_ref: None, - encryption: None, - origin_fetch_ref: Some(OriginSegmentFetchRef { - resolved_origin_url: "http://origin.example.com/123.ts".to_string(), - byte_range: None, - valid_until_ms: None, - }), - status: SegmentCacheStatus::Ready { content_length: 12, ready_at_ms: 1_000 }, - last_rendered_at_ms: None, - access: Arc::new(CacheAccessState::new()), - }, - ); - } - - let segment_file = HlsSegmentFile { proxy_seq: 123, extension: "ts".to_string() }; - assert!(!super::hls_segment_request_requires_origin_work(&session, &segment_file).await); - assert!(super::hls_origin_binding_needs_reacquire(&session).await); - } - - async fn activate_test_hls_access_lease( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_id: &str, - now_ms: u64, - ttl_ms: u64, - ) { - let lease_id = HlsAccessLeaseId(lease_id.to_string()); - let valid_window_ms = ttl_ms.saturating_mul(10).max(ttl_ms); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - valid_window_ms, - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - proxy_session_id, - now_ms, - HlsAccessLeaseTiming { active_window_ms: ttl_ms, valid_window_ms }, - ) - .await - .is_activated()); - } - - async fn register_test_hls_stream_for_lease_release( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - provider: &str, - ) { - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.max_connections = 1; - app_state - .active_users - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "hls-session-token", - virtual_id: 12345, - provider, - stream_url: "http://origin.example.com/live/user/pass/12345.m3u8", - addr: &test_addr(), - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - let mut stream_channel = - super::fallback_hls_cache_stream_channel(0, 12345, &session.read().await.origin_source, proxy_session_id); - stream_channel.shared = true; - stream_channel.shared_stream_id = Some(super::hls_cache_shared_stream_id(proxy_session_id)); - app_state - .connection_manager - .update_connection(crate::api::model::ConnectionParams { - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &test_fingerprint(), - provider: Arc::from(provider), - stream_channel: &stream_channel, - user_agent: std::borrow::Cow::Borrowed("test"), - session_token: Some("hls-session-token"), - }) - .await; - } - - fn test_segment_entry( - proxy_session_id: &ProxySessionId, - proxy_seq: u64, - status: SegmentCacheStatus, - ) -> SegmentEntry { - SegmentEntry { - origin_key: OriginSegmentKey { - origin_epoch: 0, - effective_host_id: 0, - host_local_sequence: proxy_seq, - host_local_index: u32::try_from(proxy_seq).unwrap_or(u32::MAX), - }, - proxy_seq, - duration_ms: 4_000, - proxy_file_ext: "ts".to_string(), - content_type: "video/mp2t".to_string(), - cache_key: SegmentCacheKey::new(proxy_session_id.clone(), proxy_seq, "ts"), - discontinuity_before: false, - program_date_time: None, - daterange_tags_before: Vec::new(), - origin_byte_range: None, - map_ref: None, - encryption: None, - origin_fetch_ref: Some(OriginSegmentFetchRef { - resolved_origin_url: format!("http://origin.example.com/{proxy_seq}.ts"), - byte_range: None, - valid_until_ms: None, - }), - status, - last_rendered_at_ms: None, - access: Arc::new(CacheAccessState::new()), - } - } - - #[tokio::test] - async fn hls_access_lease_idle_releases_user_but_keeps_origin_binding_and_queues() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let binding = session.read().await.origin_account_binding.clone().expect("binding exists"); - let account_name = Arc::from("account-a"); - app_state.active_provider.refresh_provider_reservation(&account_name, &binding.session_owner, 60).await; - assert!( - app_state.active_provider.is_provider_reserved_for_other_session(&account_name, Some("other-owner")).await - ); - activate_test_hls_access_lease(&app_state, &proxy_session_id, "detach-lease", 1_000, 1_000).await; - register_test_hls_stream_for_lease_release(&app_state, &session, &proxy_session_id, input.name.as_ref()).await; - assert_eq!(app_state.active_users.active_streams().await.len(), 1); - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - 1_000, - ) - .await; - { - let mut session = session.write().await; - assert_eq!(session.activity.active_access_lease_count, 1); - session.segments.insert(1, test_segment_entry(&proxy_session_id, 1, SegmentCacheStatus::Discovered)); - session.queue_segment_fetch_candidate(1, SegmentFetchPriority::Prefetch, 1_100); - session.segments.insert( - 2, - test_segment_entry( - &proxy_session_id, - 2, - SegmentCacheStatus::Ready { content_length: 12, ready_at_ms: 1_000 }, - ), - ); - let map_id = ProxyMapId(1); - let mut map = MapEntry::new( - &proxy_session_id, - map_id, - OriginMapKey { - origin_epoch: 0, - resolved_origin_uri: "http://origin.example.com/init.mp4".to_string(), - byte_range: None, - }, - "mp4".to_string(), - ); - map.status = MapCacheStatus::Queued { queued_at_ms: 1_100 }; - session.maps.insert(map_id, map); - } - let old_generation = session.read().await.activity.origin_work_generation; - - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - 3_000, - ) - .await; - - { - let session = session.read().await; - assert_eq!(session.activity.active_access_lease_count, 0); - let binding = session.origin_account_binding.as_ref().expect("binding is retained"); - assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); - assert_eq!(session.activity.origin_work_generation, old_generation); - assert!(!session.segment_prefetch_queue.is_empty()); - assert!(matches!( - session.segments.get(&1).expect("queued segment remains").status, - SegmentCacheStatus::Queued { .. } - )); - assert!(matches!( - session.segments.get(&2).expect("ready segment remains").status, - SegmentCacheStatus::Ready { .. } - )); - assert!(matches!( - session.maps.get(&ProxyMapId(1)).expect("map remains").status, - MapCacheStatus::Queued { .. } - )); - } - assert!( - app_state.active_provider.is_provider_reserved_for_other_session(&account_name, Some("other-owner")).await - ); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn hls_access_lease_sync_keeps_binding_and_queue_when_active_count_remains_positive() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - activate_test_hls_access_lease(&app_state, &proxy_session_id, "still-active-lease", 1_000, 10_000).await; - { - let mut session = session.write().await; - session.activity.active_access_lease_count = 1; - session.segments.insert(1, test_segment_entry(&proxy_session_id, 1, SegmentCacheStatus::Discovered)); - session.queue_segment_fetch_candidate(1, SegmentFetchPriority::Prefetch, 1_100); - } - let old_generation = session.read().await.activity.origin_work_generation; - - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - 1_500, - ) - .await; - - let session = session.read().await; - assert_eq!(session.activity.active_access_lease_count, 1); - assert!(matches!( - session.origin_account_binding.as_ref().expect("binding exists").binding_mode, - HlsOriginAccountBindingMode::Active - )); - assert_eq!(session.activity.origin_work_generation, old_generation); - assert!(!session.segment_prefetch_queue.is_empty()); - assert!(matches!( - session.segments.get(&1).expect("segment remains queued").status, - SegmentCacheStatus::Queued { .. } - )); - } - - #[tokio::test] - async fn hls_access_lease_sync_zero_to_zero_is_idempotent() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let old_generation = session.read().await.activity.origin_work_generation; - - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - 3_000, - ) - .await; - - let session = session.read().await; - assert_eq!(session.activity.active_access_lease_count, 0); - assert!(matches!( - session.origin_account_binding.as_ref().expect("binding exists").binding_mode, - HlsOriginAccountBindingMode::Active - )); - assert_eq!(session.activity.origin_work_generation, old_generation); - } - - #[tokio::test] - async fn hls_access_lease_gc_prepass_releases_user_without_detaching_origin_binding() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; - let proxy_session_id = session.read().await.proxy_session_id.clone(); - activate_test_hls_access_lease(&app_state, &proxy_session_id, "gc-expired-lease", 1_000, 1_000).await; - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - 1_000, - ) - .await; - let old_generation = session.read().await.activity.origin_work_generation; - - app_state - .hls_proxy - .sync_all_session_access_leases_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - 3_000, - ) - .await; - let _ = app_state.hls_proxy.run_garbage_collection_once(3_000).await.expect("gc should run"); - - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("detach keeps shared hls session"); - let session = session.read().await; - assert_eq!(session.activity.active_access_lease_count, 0); - assert!(matches!( - session.origin_account_binding.as_ref().expect("binding exists").binding_mode, - HlsOriginAccountBindingMode::Active - )); - assert_eq!(session.activity.origin_work_generation, old_generation); - } - - fn test_addr() -> SocketAddr { "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!()) } - - fn test_addr_with_port(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) } - - fn test_fingerprint() -> Fingerprint { Fingerprint::new("test".to_string(), "127.0.0.1".to_string(), test_addr()) } - - fn test_fingerprint_with_addr(addr: SocketAddr) -> Fingerprint { - Fingerprint::new(format!("test-{}", addr.port()), "127.0.0.1".to_string(), addr) - } - - async fn create_active_hls_user_session(app_state: &Arc) { - create_active_hls_user_session_with( - app_state, - "hls-session-token", - "origin-provider", - "http://origin.example.com/live/12345.m3u8", - test_addr(), - ) - .await; - } - - async fn create_active_hls_user_session_with( - app_state: &Arc, - session_token: &str, - provider: &str, - stream_url: &str, - addr: SocketAddr, - ) { - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.max_connections = 1; - app_state - .active_users - .create_user_session(CreateUserSessionParams { - user: &user, - session_token, - virtual_id: 12345, - provider, - stream_url, - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - } - - async fn mark_hls_user_session_exhausted(app_state: &Arc) { - let user = app_state.app_config.get_user_credentials("hls-user").expect("configured HLS test user"); - let addr = test_addr(); - app_state - .active_users - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "hls-session-token", - virtual_id: 12345, - provider: "origin-provider", - stream_url: "http://origin.example.com/live/12345.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Exhausted, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - } - - fn test_hls_access_context( - proxy_session_id: ProxySessionId, - access_lease_id: HlsAccessLeaseId, - ) -> HlsAccessContext { - test_hls_access_context_with(proxy_session_id, access_lease_id, "hls-session-token", test_fingerprint().key) - } - - fn test_hls_access_context_with( - proxy_session_id: ProxySessionId, - access_lease_id: HlsAccessLeaseId, - user_session_token: &str, - client_fingerprint: String, - ) -> HlsAccessContext { - HlsAccessContext { - username: "hls-user".to_string(), - user_session_token: user_session_token.to_string(), - proxy_session_id, - input_id: 1, - stream_ref: "12345".to_string(), - virtual_id: 12345, - known_bitrate_bps: None, - lease_id: access_lease_id, - family_key: HlsPlaybackFamilyKey::new("hls-user", client_fingerprint), - epg_reference_ts: None, - archive_origin_url: None, - } - } - - #[tokio::test] - async fn hls_cache_stream_channel_uses_archive_epg_context() { - let app_state = test_app_state(); - let mut access = test_hls_access_context( - ProxySessionId("proxy-archive".to_string()), - HlsAccessLeaseId("lease-archive".to_string()), - ); - access.epg_reference_ts = Some(1_784_898_000); - access.archive_origin_url = Some("http://provider/channel/timeshift_abs-1784898000.m3u8".to_string()); - let origin_source = - HlsOriginSource::new(1, Arc::from("test-input"), "80510", HlsOriginSourceKind::M3uMediaPlaylist) - .with_archive_reference(1_784_898_000); - - let channel = super::build_hls_cache_stream_channel( - &app_state, - &access, - &origin_source, - &ProxySessionId("proxy-archive".to_string()), - ) - .await; - - assert_eq!(channel.item_type, PlaylistItemType::Catchup); - assert_eq!(channel.cluster, XtreamCluster::Video); - assert_eq!(channel.epg_reference_ts, Some(1_784_898_000)); - } - - #[tokio::test] - async fn hls_cache_manifest_context_restores_leased_archive_origin() -> Result<(), StatusCode> { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let input = ConfigInput { - id: 1, - name: Arc::from("test-input"), - input_type: InputType::M3u, - enabled: true, - ..ConfigInput::default() - }; - let mut target = test_m3u_hls_share_target(); - target.name = "default".to_string(); - store_test_sources_with_target(&app_state, input.clone(), target.clone()); - cache_test_m3u_hls_item( - &app_state, - &target, - test_m3u_hls_item(&input, 12345, "80510", "http://provider/channel/mono.m3u8"), - ) - .await; - - let archive_url = "http://provider/channel/timeshift_abs-1784898000.m3u8"; - let mut access = test_hls_access_context( - ProxySessionId("proxy-archive".to_string()), - HlsAccessLeaseId("lease-archive".to_string()), - ); - access.stream_ref = "80510".to_string(); - access.epg_reference_ts = Some(1_784_898_000); - access.archive_origin_url = Some(archive_url.to_string()); - - let context = - super::resolve_hls_playback_manifest_request_context(&app_state, &access, &HeaderMap::new()).await?; - - assert_eq!(context.hls_url, archive_url); - assert_eq!(context.origin_source.stream_ref, "80510"); - assert_eq!(context.origin_source.archive_reference, Some(1_784_898_000)); - Ok(()) - } - - async fn prepare_pending_test_hls_access_lease( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - ) { - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - access_lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "12345".to_string(), - 12345, - super::current_time_millis(), - super::hls_access_lease_ttl_ms(app_state), - )) - .await; - } - - #[tokio::test] - async fn hls_cache_manifest_cold_start_synchronously_returns_initial_manifest() { - let origin = spawn_test_segment_origin( - b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", - ) - .await; - let input_name = Arc::::from("test-input"); - let input = ConfigInput { - id: 1, - name: Arc::clone(&input_name), - input_type: InputType::Xtream, - url: origin.base_url.clone(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("hls cache should handle valid live hls entrypoint"); - - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); - assert!(body.contains(&format!("/hls/shared/live/{}/{}/000000.ts", proxy_session_id.0, access_lease_id.0))); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - let session = app_state - .hls_proxy - .sessions() - .get_by_key(&session_key) - .await - .expect("cold start should create shared hls session"); - { - let session = session.read().await; - assert!(session.last_rendered_manifest.is_some()); - let binding = session.origin_account_binding.as_ref().expect("plain http input still has account binding"); - assert_eq!(binding.input_name.as_ref(), "test-input"); - assert_eq!(binding.account_name.as_ref(), "test-input"); - } - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn canonical_recovery_from_provisioning_marks_normal_handoff_boundary() { - let origin = spawn_test_segment_origin( - b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("test-input"), - input_type: InputType::Xtream, - url: origin.base_url.clone(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), 12345, super::current_time_millis()); - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("canonical hls cache should recover from provisioning"); - - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); - assert!(body.contains("#EXT-X-DISCONTINUITY\n#EXTINF:4.000,")); - assert!(!app_state.hls_provisioning.has_consumer(&input.name, 12345, super::current_time_millis())); - } - - #[tokio::test] - async fn canonical_recovery_from_provisioning_marks_transient_handoff_boundary() { - let origin = spawn_test_segment_origin( - b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"key.key\"\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("test-input"), - input_type: InputType::Xtream, - url: origin.base_url.clone(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), 12345, super::current_time_millis()); - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Activated, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("canonical hls cache should recover from provisioning"); - - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); - assert!(body.contains("#EXT-X-DISCONTINUITY\n#EXTINF:4.0,")); - assert!(!app_state.hls_provisioning.has_consumer(&input.name, 12345, super::current_time_millis())); - } - - #[tokio::test] - async fn hls_cache_manifest_cold_start_supports_m3u_hls_origin_source() { - let origin = spawn_test_segment_origin( - b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:77\n#EXTINF:4.0,\nseg-77.ts\n#EXTINF:4.0,\nseg-78.ts\n#EXTINF:4.0,\nseg-79.ts\n", - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("m3u-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/channel/index.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("hls cache should handle m3u hls media playlists"); - - assert_eq!(response.status(), StatusCode::OK); - let session = app_state - .hls_proxy - .sessions() - .get_by_key(&session_key) - .await - .expect("m3u hls should create shared session"); - let session = session.read().await; - assert_eq!(session.origin_source.source_kind, HlsOriginSourceKind::M3uMediaPlaylist); - let binding = session.origin_account_binding.as_ref().expect("m3u hls input still has account binding"); - assert_eq!(binding.input_name.as_ref(), "m3u-input"); - assert_eq!(binding.account_name.as_ref(), "m3u-input"); - } - - const AES_TEST_MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:77\n#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",KEYFORMAT=\"identity\",KEYFORMATVERSIONS=\"1\"\n#EXTINF:12,\n77.ts\n#EXTINF:12,\n78.ts\n#EXTINF:12,\n79.ts\n#EXTINF:12,\n80.ts\n#EXTINF:12,\n81.ts\n#EXTINF:12,\n82.ts\n"; - const AES_TEST_KEY_BYTES: &[u8] = b"0123456789abcdef"; - const AES_TEST_ROTATED_KEY_BYTES: &[u8] = b"fedcba9876543210"; - const AES_TEST_PLAINTEXT_SEGMENT: &[u8] = - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")); - - struct AesEndpointFixture { - _temp_dir: tempfile::TempDir, - origin: TestSegmentOrigin, - input: ConfigInput, - app_state: Arc, - request_url: String, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - access_lease_id: HlsAccessLeaseId, - key_uri: String, - base_manifest: HlsLeaseManifestSnapshot, - asset: Arc, - } - - async fn assert_aes_live_endpoint( - app_state: &Arc, - origin: &TestSegmentOrigin, - access_lease_id: &HlsAccessLeaseId, - live_body: &str, - ) -> String { - let key_uri = live_body - .lines() - .find(|line| line.starts_with("#EXT-X-KEY:METHOD=AES-128")) - .and_then(|line| line.split_once("URI=\"").map(|(_, tail)| tail)) - .and_then(|tail| tail.split_once('"').map(|(uri, _)| uri.to_string())) - .expect("opaque key URI"); - assert!(key_uri.contains(&format!("/{}/r/", access_lease_id.0))); - assert!(!live_body.contains("key.bin")); - assert!(live_body.contains("#EXT-X-VERSION:5\n")); - let proxy_media_sequence = live_body - .lines() - .find_map(|line| line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:")) - .and_then(|value| value.parse::().ok()) - .expect("proxy media sequence"); - assert_ne!(proxy_media_sequence, 77); - assert!(live_body.contains("IV=0x0000000000000000000000000000004d")); - assert_eq!(origin.key_request_count(), 1); - let segment_uris = live_body - .lines() - .filter(|line| line.starts_with("/hls/shared/live/") && !line.contains("/r/")) - .collect::>(); - assert_eq!(segment_uris.len(), 6); - for (index, segment_uri) in segment_uris.into_iter().enumerate() { - let response = get_response(Arc::clone(app_state), segment_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - let served = response_body(response).await; - let origin_sequence = 77_u64.saturating_add(u64::try_from(index).expect("test segment index")); - let expected = encrypt_test_aes128_cbc_pkcs7( - AES_TEST_PLAINTEXT_SEGMENT, - AES_TEST_KEY_BYTES, - test_hls_sequence_iv(origin_sequence), - ); - assert_eq!(served.as_ref(), expected.as_slice()); - assert_ne!(served.as_ref(), AES_TEST_PLAINTEXT_SEGMENT); - assert_eq!(origin.key_request_count(), 1); - } - key_uri - } - - async fn aes_endpoint_fixture() -> AesEndpointFixture { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let origin = spawn_test_encrypted_hls_origin( - AES_TEST_MANIFEST, - Arc::from(AES_TEST_KEY_BYTES), - Arc::from(AES_TEST_PLAINTEXT_SEGMENT), - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("encrypted-m3u-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_hls_proxy_and_inputs( - Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)), - vec![Arc::new(input.clone())], - ); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/channel/index.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("encrypted-normal-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("compatible AES origin should enter normal HLS cache"); - assert_eq!(response.status(), StatusCode::OK); - let live_body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - let key_uri = assert_aes_live_endpoint(&app_state, &origin, &access_lease_id, &live_body).await; - assert_eq!(origin.segment_request_count(), 6); - let session = app_state.hls_proxy.sessions().get_by_key(&session_key).await.expect("normal encrypted session"); - assert_eq!(session.read().await.mode, HlsSessionMode::NormalCacheTimeline); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("live encrypted lease snapshot"); - let base_manifest = lease.last_manifest_snapshot.expect("authoritative lease manifest"); - assert_eq!(base_manifest.delivery_mode, HlsManifestDeliveryMode::NormalCacheTimeline); - assert_eq!( - base_manifest.active_encryption.as_ref().and_then(|encryption| encryption.iv.as_deref()), - Some("0x00000000000000000000000000000052") - ); - let asset = terminal_test_asset(); - let evidence = prepare_terminal_base_evidence( - &session, - app_state.hls_proxy.segment_cache(), - &base_manifest, - super::current_time_millis(), - ) - .await; - assert_eq!(evidence.track_signature(), Some(asset.track_signature().clone())); - assert_eq!(evidence.key_bindings().len(), 1); - assert_eq!(origin.key_request_count(), 1); - evidence.release(); - AesEndpointFixture { - _temp_dir: temp_dir, - origin, - input, - app_state, - request_url, - session, - proxy_session_id, - access_lease_id, - key_uri, - base_manifest, - asset, - } - } - - async fn install_aes_terminal_plan(fixture: &AesEndpointFixture) -> TransientObjectCacheKey { - let key_response = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; - assert_eq!(key_response.status(), StatusCode::OK); - assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_KEY_BYTES); - assert_eq!(fixture.origin.key_request_count(), 1); - let evidence_key_id = { - let mut session = fixture.session.write().await; - let key_id = session - .transient - .resources - .values() - .find(|resource| resource.kind == TransientResourceKind::Key) - .map(|resource| resource.id.clone()) - .expect("normal key resource"); - session.last_rendered_manifest = None; - session.transient.last_manifest_body = None; - session.transient.resources.get_mut(&key_id).expect("key resource").expires_at_ms = 0; - for (key, object) in &mut session.transient.object_cache { - if key.transient_resource_id() == &key_id { - object.expires_at_ms = 0; - } - } - key_id - }; - let evidence = prepare_terminal_base_evidence( - &fixture.session, - fixture.app_state.hls_proxy.segment_cache(), - &fixture.base_manifest, - super::current_time_millis(), - ) - .await; - assert_eq!(evidence.track_signature(), Some(fixture.asset.track_signature().clone())); - assert_eq!(fixture.origin.key_request_count(), 1); - fixture - .app_state - .hls_proxy - .run_garbage_collection_once(super::current_time_millis()) - .await - .expect("evidence-pinned GC"); - { - let session = fixture.session.read().await; - assert!(session.transient.resources.contains_key(&evidence_key_id)); - assert!(session.transient.object_cache.keys().any(|key| key.transient_resource_id() == &evidence_key_id)); - } - let plan = build_terminal_tail_plan(HlsTerminalTailBuildInput { - generation: HlsTerminalTailGeneration(23), - created_at_ms: super::current_time_millis(), - base_availability: evidence.availability(), - base_track_signature: evidence.track_signature(), - base_splice_evidence: evidence.splice_evidence().cloned(), - terminal_splice_evidence: Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test( - &fixture.asset, - )), - base_timing: evidence.timing().cloned(), - base_key_bindings: evidence.key_bindings(), - expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable( - HlsTerminalAssetIdentity::from_asset(&fixture.asset), - ), - base_manifest: fixture.base_manifest.clone(), - anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test( - &fixture.asset, - fixture.base_manifest.target_duration_ms, - ), - asset: Arc::clone(&fixture.asset), - }) - .expect("READY AES key permits safe terminal reset"); - let protection = HlsTerminalTailProtection { - generation: plan.generation, - base_proxy_seqs: Arc::clone(&plan.protected_base_proxy_seqs), - key_bindings: plan.key_bindings(), - }; - assert_eq!(protection.key_bindings.len(), 1); - assert_eq!(protection.key_bindings[0].resource_id(), &evidence_key_id); - let frozen_source_cache_key = protection.key_bindings[0].source_cache_key().clone(); - { - let mut leases = fixture.app_state.hls_proxy.access_leases().write().await; - let mut lease = leases.remove_access_lease(&fixture.access_lease_id).expect("live lease"); - lease.playback_mode = HlsLeasePlaybackMode::TerminalTail(Arc::new(plan)); - leases.prepare_access_lease(lease); - } - fixture.session.write().await.install_terminal_tail_protection(fixture.access_lease_id.clone(), protection); - evidence.release(); - frozen_source_cache_key - } - - async fn assert_aes_terminal_endpoints(fixture: &AesEndpointFixture) { - let manifest_uri = - format!("/hls/shared/live/{}/{}/manifest.m3u8", fixture.proxy_session_id.0, fixture.access_lease_id.0); - let response = get_response(Arc::clone(&fixture.app_state), &manifest_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal utf8"); - let key = body.find("#EXT-X-KEY:METHOD=AES-128").expect("base AES key"); - let reset = body.find("#EXT-X-KEY:METHOD=NONE").expect("clear key reset"); - let discontinuity = body[reset..].find("#EXT-X-DISCONTINUITY").expect("terminal discontinuity") + reset; - assert!(key < reset && reset < discontinuity); - assert!(body.contains("IV=0x00000000000000000000000000000052")); - assert!(body.contains("#EXT-X-VERSION:5\n")); - assert!(body.ends_with("#EXT-X-ENDLIST\n")); - let key_response = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; - assert_eq!(key_response.status(), StatusCode::OK); - assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_KEY_BYTES); - assert_eq!(fixture.origin.key_request_count(), 1); - let range = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, Some("bytes=4-7")).await; - assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response_body(range).await.as_ref(), &AES_TEST_KEY_BYTES[4..=7]); - let unsatisfiable = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, Some("bytes=16-")).await; - assert_eq!(unsatisfiable.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(unsatisfiable.headers()[header::CONTENT_RANGE], "bytes */16"); - } - - async fn assert_aes_rotated_live_key( - fixture: &AesEndpointFixture, - frozen_source_cache_key: &TransientObjectCacheKey, - ) -> String { - let manifest_requests_before = fixture.origin.manifest_request_count(); - let segment_requests_before = fixture.origin.segment_request_count(); - fixture.origin.set_key_bytes(Arc::from(AES_TEST_ROTATED_KEY_BYTES)).await; - let live_lease_id = HlsAccessLeaseId("encrypted-rotated-live-lease".to_string()); - let access_context = test_hls_access_context(fixture.proxy_session_id.clone(), live_lease_id.clone()); - prepare_pending_test_hls_access_lease(&fixture.app_state, &fixture.proxy_session_id, &live_lease_id).await; - let response = super::try_hls_cache_canonical_manifest_response( - &fixture.app_state, - &test_fingerprint(), - &access_context, - &fixture.proxy_session_id, - &live_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &fixture.request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&fixture.request_url), - input: &fixture.input, - origin_source: super::build_hls_origin_source(&fixture.input, "12345"), - }, - HeaderMap::new(), - None, - "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("a new live lease should reuse the recovered shared session"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(fixture.origin.manifest_request_count(), manifest_requests_before.saturating_add(1)); - assert_eq!(fixture.origin.segment_request_count(), segment_requests_before); - let live_lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&live_lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await - .expect("rotated live lease snapshot"); - assert_eq!(live_lease.playback_mode, HlsLeasePlaybackMode::Live); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("rotated manifest utf8"); - let rotated_key_uri = body - .lines() - .find(|line| line.starts_with("#EXT-X-KEY:METHOD=AES-128")) - .and_then(|line| line.split_once("URI=\"").map(|(_, tail)| tail)) - .and_then(|tail| tail.split_once('"').map(|(uri, _)| uri.to_string())) - .expect("rotated live key URI"); - let key_response = get_response(Arc::clone(&fixture.app_state), &rotated_key_uri, None).await; - assert_eq!(key_response.status(), StatusCode::OK); - assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_ROTATED_KEY_BYTES); - assert_eq!(fixture.origin.key_request_count(), 2); - assert!(fixture - .app_state - .hls_proxy - .segment_cache() - .metadata(frozen_source_cache_key) - .await - .expect("A metadata lookup") - .is_none()); - let terminal_key = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; - assert_eq!(terminal_key.status(), StatusCode::OK); - assert_eq!(response_body(terminal_key).await.as_ref(), AES_TEST_KEY_BYTES); - assert_eq!(fixture.origin.key_request_count(), 2); - rotated_key_uri - } - - async fn expire_aes_terminal_lease(fixture: &AesEndpointFixture, rotated_key_uri: &str) { - let expired_at_ms = super::current_time_millis().saturating_sub(1); - { - let mut leases = fixture.app_state.hls_proxy.access_leases().write().await; - let mut lease = leases.remove_access_lease(&fixture.access_lease_id).expect("terminal lease"); - lease.valid_until_ms = expired_at_ms; - leases.prepare_access_lease(lease); - } - fixture - .app_state - .hls_proxy - .handle_lifecycle_event( - &fixture.app_state.active_users, - &fixture.app_state.active_provider, - HlsLifecycleEvent { - key: HlsLifecycleEventKey::AccessLeaseValidity { - lease_id: fixture.access_lease_id.clone(), - proxy_session_id: fixture.proxy_session_id.clone(), - }, - due_at_ms: expired_at_ms, - }, - super::current_time_millis(), - ) - .await; - fixture - .app_state - .hls_proxy - .run_garbage_collection_once(super::current_time_millis()) - .await - .expect("released GC"); - assert!(fixture.session.read().await.terminal_tail_protection(&fixture.access_lease_id).is_none()); - assert_eq!( - get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await.status(), - StatusCode::NOT_FOUND - ); - let live_key = get_response(Arc::clone(&fixture.app_state), rotated_key_uri, None).await; - assert_eq!(live_key.status(), StatusCode::OK); - assert_eq!(response_body(live_key).await.as_ref(), AES_TEST_ROTATED_KEY_BYTES); - } - - #[tokio::test] - async fn aes_128_normal_origin_key_and_terminal_lifecycle_are_endpoint_safe() { - let fixture = aes_endpoint_fixture().await; - let frozen_source_cache_key = install_aes_terminal_plan(&fixture).await; - assert_aes_terminal_endpoints(&fixture).await; - let rotated_key_uri = assert_aes_rotated_live_key(&fixture, &frozen_source_cache_key).await; - expire_aes_terminal_lease(&fixture, &rotated_key_uri).await; - } - - #[tokio::test] - async fn hls_cache_manifest_unpublished_lease_uses_same_finite_fallback_for_created_and_reused_session() { - let mut rendered_bodies = Vec::new(); - for expected_outcome in [HlsSessionStoreOutcome::Created, HlsSessionStoreOutcome::Reused] { - let input_name = Arc::::from("test-input"); - let origin = spawn_test_status_origin(StatusCode::NOT_FOUND, b"missing").await; - let input = ConfigInput { - id: 1, - name: Arc::clone(&input_name), - input_type: InputType::Xtream, - url: origin.base_url.clone(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - enable_channel_unavailable_custom_response(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - if expected_outcome == HlsSessionStoreOutcome::Reused { - let (session, outcome) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key, - origin_source.clone(), - &app_state.get_encrypt_secret(), - super::current_time_millis(), - ) - .await; - assert_eq!(outcome, HlsSessionStoreOutcome::Created); - session.write().await.origin_control.path_condition = HlsOriginPathCondition::AcceptanceConflict; - } - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: request_url.as_str(), - session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url.as_str()), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("hls cache should handle valid live hls entrypoint"); - - assert_eq!(response.status(), StatusCode::OK, "session outcome: {expected_outcome:?}"); - assert!( - response.headers().get(header::RETRY_AFTER).is_none(), - "custom response must not expose retry-after" - ); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - assert!(body.contains("#EXT-X-ENDLIST")); - assert!(!body.contains("/hls/shared/live/"), "standalone response must not expose an unready normal URI"); - rendered_bodies.push(body); - - let snapshot = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("lease remains available for strict cold-start handling"); - assert_eq!(snapshot.playback_mode, HlsLeasePlaybackMode::Live); - assert!(snapshot.last_manifest_snapshot.is_none()); - } - assert_eq!(rendered_bodies[0], rendered_bodies[1]); - } - - #[tokio::test] - async fn hls_cache_manifest_cold_start_client_abort_does_not_leave_refresh_in_flight() { - let origin = spawn_test_transient_origin_with_delayed_response( - "200 OK", - &[("Content-Type", "application/vnd.apple.mpegurl")], - "#EXTM3U\n#EXT-X-VERSION:3\n", - Duration::from_millis(100), - ) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("test-input"), - input_type: InputType::Xtream, - url: origin.base_url.clone(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let hls_dto = - HlsCacheConfigDto { origin_manifest_timeout_ms: shared::model::Millis::new(1_000), ..Default::default() }; - let hls_config = HlsCacheConfig::from(&hls_dto); - let app_state = test_app_state_with_hls_proxy_and_inputs( - Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config)), - vec![Arc::new(input.clone())], - ); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - - let app_state_for_request = Arc::clone(&app_state); - let request_handle = tokio::spawn(async move { - super::try_hls_cache_canonical_manifest_response( - &app_state_for_request, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: &request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - }); - - let session = wait_for_hls_test_session(&app_state, &session_key).await; - wait_for_hls_refresh_in_flight(&session).await; - request_handle.abort(); - let _ = request_handle.await; - - for _ in 0..200 { - if !session.read().await.origin_refresh.in_flight { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - let session = session.read().await; - assert!(!session.origin_refresh.in_flight); - assert!(session.origin_refresh.last_fetch_finished_at_ms.is_some()); - } - - #[tokio::test] - async fn hls_cache_canonical_prepare_service_unavailable_sets_retry_after() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let input = ConfigInput { - id: 1, - name: Arc::from("alias-input"), - aliases: Some(vec![crate::model::ConfigInputAlias { - id: 2, - name: Arc::from("alias-account"), - url: "http://alias.example.com".to_string(), - username: Some("alias-user".to_string()), - password: Some("alias-pass".to_string()), - priority: 0, - max_connections: 1, - exp_date: None, - enabled: true, - stalker: None, - }]), - ..ConfigInput::default() - }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("canonical hls cache response"); - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers().get(header::RETRY_AFTER).expect("retry after"), "2"); - } - - #[tokio::test] - async fn hls_origin_account_rebind_failure_sets_backoff_without_changing_session_identity() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let input = ConfigInput { id: 1, name: Arc::from("stale-input"), ..ConfigInput::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - let (session, _) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key.clone(), - origin_source.clone(), - &app_state.get_encrypt_secret(), - 1_000, - ) - .await; - { - let mut session_guard = session.write().await; - session_guard.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from("removed-account"), - &proxy_session_id, - 1_000, - )); - } - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: request_url, - session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url), - input: &input, - origin_source, - }, - HeaderMap::new(), - None, - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("canonical hls cache response"); - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers().get(header::RETRY_AFTER).expect("retry after"), "2"); - let session_guard = session.read().await; - assert_eq!(session_guard.key, session_key); - assert_eq!(session_guard.proxy_session_id, proxy_session_id); - let binding = session_guard.origin_account_binding.as_ref().expect("stale binding remains"); - assert_eq!(binding.account_name.as_ref(), "removed-account"); - assert_eq!(binding.generation, 0); - assert_eq!(session_guard.origin_account_rebind.consecutive_rebind_failures, 1); - assert!(session_guard.origin_account_rebind.next_rebind_allowed_at_ms.is_some()); - } - - #[tokio::test] - async fn hls_cache_entry_returns_master_playlist_without_origin_refresh_or_session_creation() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - - let response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - Some("/iptv"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok()), - Some("application/vnd.apple.mpegurl") - ); - assert_eq!( - response.headers().get(header::CACHE_CONTROL).and_then(|value| value.to_str().ok()), - Some("private, no-store, no-cache, must-revalidate") - ); - assert!(response.headers().get(header::LOCATION).is_none()); - assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); - assert!(response.headers().get(header::VARY).is_none()); - let content_length = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .expect("content length"); - let body = response_body(response).await; - assert_eq!(content_length, body.len()); - let body = std::str::from_utf8(&body).expect("master playlist should be UTF-8"); - assert_eq!(body.matches("/iptv").count(), 1); - let variant_uri = body.lines().nth(2).expect("single variant URI"); - assert!(variant_uri.starts_with("/iptv/hls/shared/live/")); - assert!(variant_uri.ends_with("/manifest.m3u8")); - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(variant_uri).to_string()); - assert_eq!(access_lease_id.0.len(), 22); - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(variant_uri).to_string()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("entry access lease"); - assert!(app_state - .active_users - .get_and_update_user_session(&lease.username, &lease.user_session_token) - .await - .is_some()); - assert!(app_state.hls_proxy.sessions().get_by_key(&session_key).await.is_none()); - assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[test] - fn hls_master_playlist_response_diagnostic_reports_complete_response_contract_for_every_bandwidth_source() { - for selection in [ - super::HlsMasterBandwidthSelection::resolve(Some(2_500_000), Some(2_000_000)), - super::HlsMasterBandwidthSelection::resolve(None, Some(2_000_000)), - super::HlsMasterBandwidthSelection::resolve(None, None), - ] { - let rendered = super::hls_entry_master_playlist_response( - &ProxySessionId("diagnostic-proxy-session".to_string()), - &HlsAccessLeaseId("diagnostic-lease".to_string()), - selection.bandwidth(), - Some("/iptv"), - ); - let fields = super::HlsMasterPlaylistResponseDiagnostic { - lease: "lease-safe".to_string(), - session: "session-safe".to_string(), - proxy_session: "proxy-safe".to_string(), - user_session: "user-safe".to_string(), - virtual_id: 12345, - bandwidth_bps: selection.bandwidth().advertised_bps(), - bandwidth_source: selection.source().as_log_value(), - content_length: rendered.content_length, - } - .to_string(); - - assert!(fields.contains("lease=lease-safe session=session-safe proxy_session=proxy-safe")); - assert!(fields.contains("user_session=user-safe virtual_id=12345")); - assert!(fields.contains(&format!( - "bandwidth_bps={} bandwidth_source={}", - selection.bandwidth().advertised_bps(), - selection.source().as_log_value() - ))); - assert!(fields.contains("status=200")); - assert!(fields.ends_with(&format!("content_length={}", rendered.content_length))); - assert_eq!( - rendered - .response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()), - Some(rendered.content_length) - ); - } - } - - struct SharedRequestFlowFixture { - _origin: TestSegmentOrigin, - app_state: Arc, - input: ConfigInput, - target: ConfigTarget, - user: ProxyUserCredentials, - origin_manifest_url: String, - entry_path: String, - } - - async fn shared_request_flow_fixture() -> SharedRequestFlowFixture { - const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n\ - #EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n\ - #EXTINF:4.0,\n000125.ts\n#EXTINF:4.0,\n000126.ts\n#EXTINF:4.0,\n000127.ts\n\ - #EXTINF:4.0,\n000128.ts\n"; - let origin = spawn_test_segment_origin(MANIFEST).await; - let input = ConfigInput { - id: 1, - name: Arc::from("request-flow-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let mut target = test_m3u_hls_share_target(); - target.name = "default".to_string(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - configure_default_test_server(&app_state); - store_test_sources_with_target(&app_state, input.clone(), target.clone()); - let origin_manifest_url = format!("{}/channel/index.m3u8", origin.base_url); - cache_test_m3u_hls_item( - &app_state, - &target, - test_m3u_hls_item(&input, 12345, "channel-a", &origin_manifest_url), - ) - .await; - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); - SharedRequestFlowFixture { _origin: origin, app_state, input, target, user, origin_manifest_url, entry_path } - } - - async fn shared_request_flow_entry( - fixture: &SharedRequestFlowFixture, - fingerprint: &Fingerprint, - ) -> Response { - super::handle_hls_stream_request( - fingerprint, - &fixture.app_state, - &fixture.user, - &fixture.target, - None, - None, - &fixture.origin_manifest_url, - None, - test_hls_entry_stream_context(12345, "channel-a", Some(2_500_000)), - &fixture.input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &fixture.entry_path, - ) - .await - .into_response() - } - - #[tokio::test] - async fn hls_cache_archive_entry_uses_distinct_identity_and_preserves_origin() -> Result<(), &'static str> { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - configure_default_test_server(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let input = test_hls_input(); - let target = test_hls_share_target(true); - let archive_url = "http://origin.example.com/live/user/pass/timeshift_abs-1784898000.m3u8"; - - let response = super::handle_hls_stream_request( - &test_fingerprint(), - &app_state, - &user, - &target, - None, - None, - archive_url, - Some(1_784_898_000), - test_hls_entry_stream_context(12345, "80510", None), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &super::build_virtual_hls_entry_path(&target, &input, &user, 12345), - ) - .await - .into_response(); - - assert_eq!(response.status(), StatusCode::OK); - let (_, media_playlist_uri) = single_variant_master_playlist(response).await; - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); - let live_source = super::build_hls_origin_source(&input, "80510"); - let archive_source = - super::build_hls_origin_source_for_playback(&input, "80510", Some(1_784_898_000), Some(archive_url)); - assert_ne!( - proxy_session_id, - build_proxy_session_id(&live_source.session_key(), &app_state.get_encrypt_secret()) - ); - assert_eq!( - proxy_session_id, - build_proxy_session_id(&archive_source.session_key(), &app_state.get_encrypt_secret()) - ); - - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); - let lease = app_state - .hls_proxy - .access_leases() - .write() - .await - .response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .ok_or("archive access lease")?; - assert_eq!(lease.stream_ref, "80510"); - assert_eq!(lease.epg_reference_ts, Some(1_784_898_000)); - assert_eq!(lease.archive_origin_url.as_deref(), Some(archive_url)); - assert!(super::is_m3u_catchup_session_token(&lease.user_session_token)); - Ok(()) - } - - #[tokio::test] - async fn shared_hls_request_flow_keeps_media_playlist_lease_bound_across_reloads() { - let fixture = shared_request_flow_fixture().await; - let app_state = &fixture.app_state; - let entry_response = shared_request_flow_entry(&fixture, &test_fingerprint()).await; - - assert_eq!(entry_response.status(), StatusCode::OK); - assert!(!entry_response.headers().contains_key(header::LOCATION)); - let (_, media_playlist_uri) = single_variant_master_playlist(entry_response).await; - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); - - let first_media_response = get_response(Arc::clone(app_state), &media_playlist_uri, None).await; - assert_eq!(first_media_response.status(), StatusCode::OK); - assert!(!first_media_response.headers().contains_key(header::LOCATION)); - let first_media_body = - String::from_utf8(response_body(first_media_response).await.to_vec()).expect("media playlist utf8"); - let segment_uri = first_media_body - .lines() - .find(|line| line.starts_with("/hls/shared/live/") && path_has_extension(line, "ts")) - .expect("lease-bound segment URI") - .to_string(); - let lease_path = format!("/{}/{}/", proxy_session_id.0, access_lease_id.0); - assert!(segment_uri.contains(&lease_path)); - - let reloaded_media_response = get_response(Arc::clone(app_state), &media_playlist_uri, None).await; - assert_eq!(reloaded_media_response.status(), StatusCode::OK); - assert!(!reloaded_media_response.headers().contains_key(header::LOCATION)); - let reloaded_media_body = String::from_utf8(response_body(reloaded_media_response).await.to_vec()) - .expect("reloaded media playlist utf8"); - assert_eq!(manifest_media_sequence(&reloaded_media_body), manifest_media_sequence(&first_media_body)); - assert!(reloaded_media_body.lines().any(|line| line == segment_uri)); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); - - let segment_response = get_response(Arc::clone(app_state), &segment_uri, None).await; - assert_eq!(segment_response.status(), StatusCode::OK); - assert!(!response_body(segment_response).await.is_empty()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("request-flow access lease"); - assert_eq!(lease.state, HlsAccessLeaseState::Activated); - assert!(lease.last_manifest_snapshot.is_some()); - - let second_entry_response = - shared_request_flow_entry(&fixture, &test_fingerprint_with_addr(test_addr_with_port(55131))).await; - assert_eq!(second_entry_response.status(), StatusCode::OK); - let (_, second_media_playlist_uri) = single_variant_master_playlist(second_entry_response).await; - let second_proxy_session_id = - ProxySessionId(proxy_session_id_from_variant_uri(&second_media_playlist_uri).to_string()); - let second_access_lease_id = - HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_media_playlist_uri).to_string()); - assert_eq!(second_proxy_session_id, proxy_session_id); - assert_ne!(second_access_lease_id, access_lease_id); - let second_pending_lease = app_state - .hls_proxy - .access_lease_response_snapshot( - &second_access_lease_id, - &second_proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("second pending request-flow lease"); - assert_eq!(second_pending_lease.state, HlsAccessLeaseState::Pending); - assert!(second_pending_lease.last_manifest_snapshot.is_none()); - assert_ne!(second_pending_lease.user_session_token, lease.user_session_token); - - let second_media_response = get_response(Arc::clone(app_state), &second_media_playlist_uri, None).await; - assert_eq!(second_media_response.status(), StatusCode::OK); - assert!(!second_media_response.headers().contains_key(header::LOCATION)); - let second_media_body = - String::from_utf8(response_body(second_media_response).await.to_vec()).expect("second media playlist utf8"); - assert_eq!(manifest_media_sequence(&second_media_body), manifest_media_sequence(&first_media_body)); - let second_published_lease = app_state - .hls_proxy - .access_lease_response_snapshot( - &second_access_lease_id, - &second_proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("second published request-flow lease"); - assert!(second_published_lease.last_manifest_snapshot.is_some()); - assert_eq!(second_published_lease.playback_mode, HlsLeasePlaybackMode::Live); - assert_eq!(app_state.hls_proxy.sessions().len().await, 1); - } - - struct PublicationLateFixture { - origin: TestSegmentOrigin, - origin_phase: Arc, - app_state: Arc, - session: HlsSessionHandle, - proxy_session_id: ProxySessionId, - access_lease_id: HlsAccessLeaseId, - media_playlist_uri: String, - } - - async fn publication_late_fixture() -> PublicationLateFixture { - let initial_manifest = Arc::<[u8]>::from(regression_origin_manifest(123, 6)); - let progressed_manifest = Arc::<[u8]>::from(regression_origin_manifest(124, 6)); - let segment = Arc::<[u8]>::from( - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) - .as_slice(), - ); - let origin_phase = Arc::new(AtomicUsize::new(0)); - let origin_phase_for_handler = Arc::clone(&origin_phase); - let origin = spawn_test_binary_origin(Arc::new(move |path| { - if path_has_extension(path, "m3u8") { - return match origin_phase_for_handler.load(Ordering::SeqCst) { - 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&initial_manifest)), - 1 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&progressed_manifest)), - _ => TestBinaryOriginResponse::new( - StatusCode::PROXY_AUTHENTICATION_REQUIRED, - Arc::<[u8]>::from(&b"retry"[..]), - ), - }; - } - TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&segment)) - })) - .await; - let input = ConfigInput { - id: 1, - name: Arc::from("publication-late-request-input"), - input_type: InputType::M3u, - url: origin.base_url.clone(), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let mut target = test_m3u_hls_share_target(); - target.name = "default".to_string(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - enable_channel_unavailable_custom_response(&app_state); - configure_default_test_server(&app_state); - store_test_sources_with_target(&app_state, input.clone(), target.clone()); - let origin_manifest_url = format!("{}/channel/index.m3u8", origin.base_url); - cache_test_m3u_hls_item( - &app_state, - &target, - test_m3u_hls_item(&input, 12345, "channel-a", &origin_manifest_url), - ) - .await; - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); - let entry_response = super::handle_hls_stream_request( - &test_fingerprint(), - &app_state, - &user, - &target, - None, - None, - &origin_manifest_url, - None, - test_hls_entry_stream_context(12345, "channel-a", None), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &entry_path, - ) - .await - .into_response(); - let (_, media_playlist_uri) = single_variant_master_playlist(entry_response).await; - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); - let initial_media_response = get_response(Arc::clone(&app_state), &media_playlist_uri, None).await; - assert_eq!(initial_media_response.status(), StatusCode::OK); - let initial_media_body = - String::from_utf8(response_body(initial_media_response).await.to_vec()).expect("initial media utf8"); - let last_segment_uri = initial_media_body - .lines() - .rfind(|line| line.starts_with("/hls/shared/live/") && path_has_extension(line, "ts")) - .expect("initial manifest segment"); - assert_eq!(get_status(Arc::clone(&app_state), last_segment_uri).await, StatusCode::OK); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("shared publication-late session"); - PublicationLateFixture { - origin, - origin_phase, - app_state, - session, - proxy_session_id, - access_lease_id, - media_playlist_uri, - } - } - - async fn refresh_publication_late_fixture(fixture: &PublicationLateFixture) -> HlsAccessLease { - let progress_generation_before = fixture.session.read().await.origin_control.progress_generation; - { - let mut session = fixture.session.write().await; - session.origin_control.last_media_progress_at_ms = Some(0); - session.origin_refresh.next_fetch_allowed_at_ms = 0; - } - let requests_before = fixture.origin.manifest_request_count(); - fixture.origin_phase.store(1, Ordering::SeqCst); - let response = get_response(Arc::clone(&fixture.app_state), &fixture.media_playlist_uri, None).await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("refreshed media utf8"); - assert!(!body.contains("#EXT-X-ENDLIST")); - assert!(!body.contains("/terminal/")); - assert!(fixture.origin.manifest_request_count() > requests_before); - { - let session = fixture.session.read().await; - assert!(session.origin_control.progress_generation > progress_generation_before); - assert_eq!(session.origin_seq_highwater, Some(129)); - } - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot( - &fixture.access_lease_id, - &fixture.proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("publication-late lease remains stored"); - assert_eq!(lease.state, HlsAccessLeaseState::Activated); - assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); - assert_eq!(fixture.app_state.hls_proxy.terminal_pending().owner_count(), 0); - lease - } - - async fn prepare_publication_late_terminal_pressure( - fixture: &PublicationLateFixture, - base_manifest: &HlsLeaseManifestSnapshot, - ) { - let terminal_response = fixture.app_state.app_config.custom_stream_response.load_full(); - let terminal_asset = snapshot_terminal_media_asset( - terminal_response - .as_ref() - .and_then(|response| response.channel_unavailable.as_ref()) - .expect("configured terminal asset"), - ) - .expect("compatible terminal asset"); - let bundle_key = prepared_terminal_bundle_key( - &terminal_asset, - base_manifest.target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let state = fixture.app_state.hls_proxy.start_prepared_terminal_bundle( - terminal_asset, - base_manifest.target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let state = match state { - HlsPreparedTerminalBundleState::Preparing { .. } => fixture - .app_state - .hls_proxy - .wait_for_prepared_terminal_bundle(bundle_key) - .await - .expect("terminal bundle completion"), - state => state, - }; - assert!(matches!(state, HlsPreparedTerminalBundleState::Ready { .. })); - let mut session = fixture.session.write().await; - for segment in session.segments.values_mut().filter(|segment| segment.proxy_seq > base_manifest.last_proxy_seq) - { - segment.duration_ms = 1; - } - session.origin_control.last_media_progress_at_ms = Some(0); - session.origin_refresh.next_fetch_allowed_at_ms = 0; - } - - async fn assert_publication_late_terminal_result(fixture: &PublicationLateFixture) { - let requests_before = fixture.origin.manifest_request_count(); - fixture.origin_phase.store(2, Ordering::SeqCst); - let response = get_response(Arc::clone(&fixture.app_state), &fixture.media_playlist_uri, None).await; - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot( - &fixture.access_lease_id, - &fixture.proxy_session_id, - super::current_time_millis(), - ) - .await - .expect("terminal lease remains stored"); - let recovery_plan = fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(); - assert!( - fixture.origin.manifest_request_count().saturating_sub(requests_before) >= recovery_plan.total_candidates() - ); - match lease.playback_mode { - HlsLeasePlaybackMode::TerminalTail(_) => { - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest utf8"); - assert!(body.contains("#EXT-X-ENDLIST")); - assert!(body.contains("/terminal/")); - } - HlsLeasePlaybackMode::TerminalUnavailable { .. } => { - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } - HlsLeasePlaybackMode::Live => { - assert_eq!(response.status(), StatusCode::OK); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("live manifest utf8"); - assert!(body.starts_with("#EXTM3U")); - assert!(!body.contains("#EXT-X-ENDLIST")); - } - HlsLeasePlaybackMode::Ended => { - panic!("active hard-failed lease must not become ended without terminal publication") - } - } - } - - #[tokio::test] - async fn publication_late_live_manifest_request_refreshes_before_terminal_evaluation() { - let fixture = publication_late_fixture().await; - let lease = refresh_publication_late_fixture(&fixture).await; - let base_manifest = lease.last_manifest_snapshot.as_ref().expect("live manifest snapshot"); - prepare_publication_late_terminal_pressure(&fixture, base_manifest).await; - assert_publication_late_terminal_result(&fixture).await; - } - - #[test] - fn hls_entry_stream_context_keeps_only_positive_live_bitrate() { - let input = ConfigInput { name: Arc::from("m3u-input"), ..ConfigInput::default() }; - let mut item = test_m3u_hls_item(&input, 1001, "channel-a", "http://origin.example.com/live.m3u8"); - item.additional_properties = Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - bitrate: 2_500_000, - ..shared::model::LiveStreamProperties::default() - }))); - - let context = super::HlsEntryStreamContext::from_playlist_item(&item).expect("entry stream context"); - assert_eq!(context.virtual_id(), 1001); - assert_eq!(context.stream_ref(), "channel-a"); - assert_eq!(context.known_bitrate_bps(), Some(2_500_000)); - - if let Some(StreamProperties::Live(properties)) = item.additional_properties.as_mut() { - properties.bitrate = 0; - } - assert_eq!( - super::HlsEntryStreamContext::from_playlist_item(&item) - .expect("entry stream context without bitrate") - .known_bitrate_bps(), - None - ); - } - - #[tokio::test] - async fn hls_cache_entry_prefers_item_bitrate_then_loads_db_without_target_rebuild() { - let temp = tempfile::tempdir().expect("temp dir"); - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut config = app_state.app_config.config.load().as_ref().clone(); - config.storage_dir = temp.path().to_string_lossy().into_owned(); - app_state.app_config.config.store(Arc::new(config)); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = - ConfigInput { id: 9, name: Arc::from("m3u-input"), input_type: InputType::M3u, ..ConfigInput::default() }; - let mut stored_item = test_m3u_hls_item(&input, 70001, "channel-a", "http://origin.example.com/live.m3u8"); - stored_item.additional_properties = - Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - bitrate: 2_500_000, - ..shared::model::LiveStreamProperties::default() - }))); - let input_storage = crate::repository::build_input_storage_path(&input.name, &temp.path().to_string_lossy()); - std::fs::create_dir_all(&input_storage).expect("input storage"); - let db_path = crate::repository::get_input_m3u_playlist_file_path(&input_storage, &input.name); - let mut tree = crate::repository::BPlusTree::new(); - tree.insert(Arc::::from("channel-a"), stored_item); - tree.store(&db_path).expect("input M3U metadata"); - let request_url = "http://origin.example.com/live.m3u8"; - - let db_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - super::build_hls_origin_source(&input, "channel-a"), - 70001, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let (db_bandwidth, db_variant_uri) = single_variant_master_playlist(db_response).await; - assert_eq!(db_bandwidth, 3_000_000); - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&db_variant_uri).to_string()); - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&db_variant_uri).to_string()); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("DB-backed access lease"); - assert_eq!(lease.known_bitrate_bps, Some(2_500_000)); - - std::fs::write(&db_path, b"invalid metadata tree").expect("corrupt test metadata"); - let item_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint_with_addr(test_addr_with_port(55127)), - &user, - super::build_hls_origin_source(&input, "channel-a"), - 70001, - None, - Some(3_000_000), - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let (item_bandwidth, _) = single_variant_master_playlist(item_response).await; - assert_eq!(item_bandwidth, 3_600_000); - - let fallback_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint_with_addr(test_addr_with_port(55128)), - &user, - super::build_hls_origin_source(&input, "channel-a"), - 70001, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let (fallback_bandwidth, _) = single_variant_master_playlist(fallback_response).await; - assert_eq!(fallback_bandwidth, 1_000_000); - } - - #[tokio::test] - async fn hls_cache_entry_denies_access_lease_for_grace_without_slot_and_exhausted() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - - for (connection_permission, connection_kind) in [ - (UserConnectionPermission::GracePeriod, None), - (UserConnectionPermission::Exhausted, Some(ConnectionKind::Normal)), - ] { - let response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - connection_permission, - connection_kind, - Some("/iptv"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - let variant_uri = single_variant_uri(response).await; - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&variant_uri).to_string()); - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); - let now_ms = super::current_time_millis(); - let snapshot = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms) - .await - .expect("denied lease should stay available for response rendering"); - assert_eq!(snapshot.state, HlsAccessLeaseState::Denied); - assert!(app_state - .hls_proxy - .access_lease_session_snapshot(&proxy_session_id, now_ms) - .await - .effective_origin_policy - .is_none()); - - let err = super::validate_hls_proxy_access_context( - &app_state, - &test_fingerprint(), - &proxy_session_id, - &access_lease_id.0, - now_ms, - HlsAccessAdmissionMode::ManifestPrepare, - ) - .await - .expect_err("denied lease must surface as admission denied"); - assert!(matches!(err, HlsAccessLeaseValidationError::AdmissionDenied { runtime_tail: None, .. })); - } - } - - #[tokio::test] - async fn hls_cache_entry_master_playlist_uses_cache_when_target_hls_share_enabled() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - configure_default_test_server(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let input = test_hls_input(); - let target = test_hls_share_target(true); - let original_hls_entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 1001); - - let response = super::handle_hls_stream_request( - &test_fingerprint(), - &app_state, - &user, - &target, - None, - None, - "http://origin.example.com/live/user/pass/1001.m3u8", - None, - test_hls_entry_stream_context(1001, "80510", Some(2_500_000)), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &original_hls_entry_path, - ) - .await - .into_response(); - - assert_eq!(response.status(), StatusCode::OK); - let (bandwidth, variant_uri) = single_variant_master_playlist(response).await; - assert_eq!(bandwidth, 3_000_000); - assert!(variant_uri.starts_with("/hls/shared/live/")); - assert!(variant_uri.ends_with("/manifest.m3u8")); - assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); - - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&variant_uri).to_string()); - let origin_key = HlsSessionKey::new(input.id, "80510"); - let virtual_id_key = HlsSessionKey::new(input.id, "1001"); - assert_eq!(origin_key.stable_value(), "input:1|hls|80510"); - assert_eq!(proxy_session_id, build_proxy_session_id(&origin_key, &app_state.get_encrypt_secret())); - assert_ne!(proxy_session_id, build_proxy_session_id(&virtual_id_key, &app_state.get_encrypt_secret())); - - let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); - let lease = app_state - .hls_proxy - .access_leases() - .write() - .await - .response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .expect("access lease"); - assert_eq!(lease.stream_ref, "80510"); - assert_eq!(lease.virtual_id, 1001); - assert_eq!(lease.known_bitrate_bps, Some(2_500_000)); - } - - #[tokio::test] - async fn hls_cache_entry_shares_content_session_across_targets_but_keeps_distinct_virtual_leases() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - configure_default_test_server(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let input = test_hls_input(); - let first_target = test_hls_share_target(true); - let mut second_target = test_hls_share_target(true); - second_target.id = 2; - - let first_response = super::handle_hls_stream_request( - &test_fingerprint(), - &app_state, - &user, - &first_target, - None, - None, - "http://origin.example.com/live/user/pass/1001.m3u8", - None, - test_hls_entry_stream_context(1001, "80510", None), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &super::build_virtual_hls_entry_path(&first_target, &input, &user, 1001), - ) - .await - .into_response(); - let second_response = super::handle_hls_stream_request( - &test_fingerprint_with_addr(test_addr_with_port(55124)), - &app_state, - &user, - &second_target, - None, - None, - "http://origin.example.com/live/user/pass/9007.m3u8", - None, - test_hls_entry_stream_context(9007, "80510", None), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &super::build_virtual_hls_entry_path(&second_target, &input, &user, 9007), - ) - .await - .into_response(); - - assert_eq!(first_response.status(), StatusCode::OK); - assert_eq!(second_response.status(), StatusCode::OK); - let first_variant_uri = single_variant_uri(first_response).await; - let second_variant_uri = single_variant_uri(second_response).await; - assert_eq!( - proxy_session_id_from_variant_uri(&first_variant_uri), - proxy_session_id_from_variant_uri(&second_variant_uri) - ); - assert_ne!( - access_lease_id_from_variant_uri(&first_variant_uri), - access_lease_id_from_variant_uri(&second_variant_uri) - ); - - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); - let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); - let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); - let now_ms = super::current_time_millis(); - let mut leases = app_state.hls_proxy.access_leases().write().await; - let first_lease = - leases.response_snapshot(&first_lease_id, &proxy_session_id, now_ms).expect("first access lease"); - let second_lease = - leases.response_snapshot(&second_lease_id, &proxy_session_id, now_ms).expect("second access lease"); - assert_eq!(first_lease.virtual_id, 1001); - assert_eq!(second_lease.virtual_id, 9007); - assert_eq!(first_lease.stream_ref, "80510"); - assert_eq!(second_lease.stream_ref, "80510"); - } - - #[tokio::test] - async fn hls_virtual_source_resolver_rejects_missing_input_stream_id_with_service_unavailable() { - let input = single_hls_provider_input("missing-origin-id-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let target = Arc::new(test_m3u_hls_share_target()); - let item = test_m3u_hls_item( - &input, - 1001, - "", - "http://account.example.com/live/account-user/account-pass/channel.m3u8", - ); - cache_test_m3u_hls_item(&app_state, &target, item).await; - - let status = super::resolve_hls_virtual_source_for_target(&app_state, &target, 1001) - .await - .expect_err("missing input stream identity must fail safely"); - - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - } - - #[tokio::test] - async fn hls_cache_entry_uses_legacy_path_when_target_hls_share_disabled() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - configure_default_test_server(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - user.password = "hls-pass".to_string(); - let input = test_hls_input(); - let target = test_hls_share_target(false); - let original_hls_entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); - - let response = super::handle_hls_stream_request( - &test_fingerprint(), - &app_state, - &user, - &target, - None, - None, - "http://origin.example.com/live/user/pass/12345.m3u8", - None, - test_hls_entry_stream_context(12345, "80510", None), - &input, - &HeaderMap::new(), - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - &original_hls_entry_path, - ) - .await - .into_response(); - - let location = response.headers().get(header::LOCATION).and_then(|value| value.to_str().ok()).unwrap_or(""); - assert!(!location.contains("/hls/shared/live/")); - assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); - assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); - } - - #[tokio::test] - async fn legacy_hls_token_route_renders_channel_unavailable_inline_when_target_hls_share_enabled() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - app_state.app_config.config.store(Arc::new(Config { - custom_stream_response_enabled: true, - reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { - hls_cache: Some(HlsCacheConfigDto::default()), - ..Default::default() - })), - ..Default::default() - })); - configure_default_test_server(&app_state); - enable_channel_unavailable_custom_response(&app_state); - let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); - let input = test_hls_input(); - let target = test_hls_share_target(true); - store_test_sources_with_target(&app_state, input.clone(), target.clone()); - let encrypt_secret = app_state.get_encrypt_secret(); - let legacy_manifest = rewrite_hls( - &user, - &RewriteHlsProps { - secret: &encrypt_secret, - base_url: "", - content: "#EXTM3U\n#EXTINF:4.0,\nseg.ts\n", - hls_url: "http://origin.example.com/live/user/pass/12345.m3u8".to_string(), - target_id: target.id, - virtual_id: 12345, - input_id: input.id, - user_token: Some("legacy-session-token"), - }, - ); - let token = legacy_manifest - .lines() - .find_map(|line| line.rsplit_once('/').map(|(_, token)| token.trim().to_string())) - .expect("legacy hls segment token should be rendered"); - - let response = super::hls_api_stream_resolved( - test_fingerprint(), - HeaderMap::new(), - Arc::clone(&app_state), - Arc::clone(&user), - Arc::new(target), - input.id, - 12345, - token, - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key(header::LOCATION)); - assert_eq!(response.headers()[header::CONTENT_TYPE], "application/vnd.apple.mpegurl"); - assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn legacy_hls_token_route_with_invalid_token_returns_bad_request_when_target_hls_share_enabled() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); - let input = test_hls_input(); - let target = test_hls_share_target(true); - store_test_sources_with_target(&app_state, input.clone(), target.clone()); - - let response = super::hls_api_stream_resolved( - test_fingerprint(), - HeaderMap::new(), - Arc::clone(&app_state), - user, - Arc::new(target), - input.id, - 12345, - "not-a-valid-legacy-token.ts".to_string(), - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - #[test] - fn cache_enabled_legacy_hls_route_only_allows_existing_m3u_catchup_session() { - assert!(super::legacy_hls_route_allowed_with_cache( - true, - Some("m3u-catchup|session"), - Some("m3u-catchup|session") - )); - assert!(super::legacy_hls_route_allowed_with_cache(true, Some("catchup|session"), Some("catchup|session"))); - assert!(!super::legacy_hls_route_allowed_with_cache( - true, - Some("m3u-catchup|session"), - Some("m3u-catchup|other") - )); - assert!(!super::legacy_hls_route_allowed_with_cache(true, Some("legacy-session"), Some("legacy-session"))); - assert!(!super::legacy_hls_route_allowed_with_cache(true, None, None)); - assert!(super::legacy_hls_route_allowed_with_cache(false, None, None)); - } - - #[tokio::test] - async fn hls_cache_canonical_manifest_rejects_when_target_hls_share_disabled() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let input = test_hls_input(); - let target = test_hls_share_target(false); - store_test_sources_with_target(&app_state, input, target); - let access_context = HlsAccessContext { - username: "hls-user".to_string(), - user_session_token: "hls-session-token".to_string(), - proxy_session_id: ProxySessionId("proxy-session".to_string()), - input_id: 1, - stream_ref: "12345".to_string(), - virtual_id: 12345, - known_bitrate_bps: None, - lease_id: HlsAccessLeaseId("access-lease".to_string()), - family_key: HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - epg_reference_ts: None, - archive_origin_url: None, - }; - - let Err(err) = - super::resolve_hls_playback_manifest_request_context(&app_state, &access_context, &HeaderMap::new()).await - else { - panic!("disabled target hls sharing should reject canonical cache path"); - }; - - assert_eq!(err, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn hls_cache_entry_leases_update_effective_origin_acquire_policy_for_shared_session() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut soft_user = ProxyUserCredentials::default(); - soft_user.username = "soft-user".to_string(); - soft_user.soft_priority = 20; - let mut normal_user = ProxyUserCredentials::default(); - normal_user.username = "normal-user".to_string(); - normal_user.priority = -5; - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let session_key = origin_source.session_key(); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - - let soft_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &soft_user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Soft), - None, - ) - .await; - assert_eq!(soft_response.status(), StatusCode::OK); - let soft_snapshot = - app_state.hls_proxy.access_lease_session_snapshot(&proxy_session_id, super::current_time_millis()).await; - let soft_policy = soft_snapshot.effective_origin_policy.expect("soft policy"); - assert_eq!(soft_policy.connection_kind, ConnectionKind::Soft); - assert_eq!(soft_policy.priority, soft_user.soft_priority); - - // Different user/family, same shared HLS session. Normal media admission must upgrade - // the future origin-account acquire policy without changing the shared session identity. - let normal_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &normal_user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - assert_eq!(normal_response.status(), StatusCode::OK); - let normal_snapshot = - app_state.hls_proxy.access_lease_session_snapshot(&proxy_session_id, super::current_time_millis()).await; - let normal_policy = normal_snapshot.effective_origin_policy.expect("normal policy"); - assert_eq!(normal_policy.connection_kind, ConnectionKind::Normal); - assert_eq!(normal_policy.priority, normal_user.priority); - - let (session, _) = app_state - .hls_proxy - .get_or_create_session_with_source_and_outcome( - session_key, - super::build_hls_origin_source(&input, "12345"), - &app_state.get_encrypt_secret(), - super::current_time_millis(), - ) - .await; - app_state - .hls_proxy - .sync_session_access_lease_count_and_detach_if_needed( - &app_state.active_users, - &app_state.active_provider, - &session, - &proxy_session_id, - super::current_time_millis(), - ) - .await; - let session_policy = session.read().await.effective_origin_acquire_policy_or_default(); - assert_eq!(session_policy.connection_kind, ConnectionKind::Normal); - assert_eq!(session_policy.priority, normal_user.priority); - } - - #[tokio::test] - async fn hls_entry_origin_reservation_requires_real_provider_handle() { - let app_state = test_app_state(); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = single_hls_provider_input("missing-provider"); - - let reservation = super::try_reserve_hls_entry_origin_account_for_redirect( - &app_state, - &test_fingerprint(), - &user, - &input, - 12345, - "http://origin.example.com/live/source-user/source-pass/12345.m3u8", - "hls-session-token", - "hls-cache:test-session", - super::hls_origin_account_reservation_ttl_secs_fallback(), - UserConnectionPermission::Allowed, - ConnectionKind::Normal, - false, - ) - .await; - - assert!(reservation.is_none(), "provisioning redirect must not use an exhausted/counter-only check"); - } - - #[tokio::test] - async fn hls_entry_origin_reservation_sets_owner_reservation_before_redirect() { - let input = single_hls_provider_input("available-provider"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let session_owner = "hls-cache:test-session"; - - let reservation = super::try_reserve_hls_entry_origin_account_for_redirect( - &app_state, - &test_fingerprint(), - &user, - &input, - 12345, - "http://account.example.com/live/account-user/account-pass/12345.m3u8", - "hls-session-token", - session_owner, - super::hls_origin_account_reservation_ttl_secs_fallback(), - UserConnectionPermission::Allowed, - ConnectionKind::Normal, - false, - ) - .await - .expect("provider reservation should be acquired before provisioning redirect"); - - assert_eq!(reservation.request_url, "http://account.example.com/live/account-user/account-pass/12345.m3u8"); - assert!(reservation.selected_provider_config.is_some()); - assert!(app_state.active_users.active_streams().await.is_empty()); - app_state.connection_manager.release_provider_handle(reservation.provider_handle).await; - - assert!( - app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &test_addr_with_port(55251), - false, - 0, - ConnectionKind::Normal, - Some("other-owner"), - ) - .await - .is_none(), - "reserved provider must stay blocked for other HLS sessions" - ); - - let same_owner_handle = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &test_addr_with_port(55252), - false, - 0, - ConnectionKind::Normal, - Some(session_owner), - ) - .await; - assert!(same_owner_handle.is_some(), "reserved provider must be reusable by the same HLS session owner"); - app_state.connection_manager.release_provider_handle(same_owner_handle).await; - } - - #[tokio::test] - async fn hls_virtual_entry_reservation_uses_input_stream_id_for_shared_session_owner() { - let input = single_hls_provider_input("origin-id-reservation-input"); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - let target = Arc::new(test_m3u_hls_share_target()); - let item = test_m3u_hls_item( - &input, - 1001, - "80510", - "http://account.example.com/live/account-user/account-pass/80510.m3u8", - ); - cache_test_m3u_hls_item(&app_state, &target, item).await; - let stream_identity = super::HlsEntryStreamIdentity::new(1001, "80510").expect("input stream identity"); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - - assert!( - super::try_reserve_hls_virtual_entry_origin_account_for_redirect( - &app_state, - &test_fingerprint(), - &user, - &target, - &input, - &stream_identity, - ) - .await - ); - - let expected_key = HlsSessionKey::new(input.id, "80510"); - let expected_proxy_session_id = build_proxy_session_id(&expected_key, &app_state.get_encrypt_secret()); - let expected_owner = crate::api::model::build_hls_origin_session_owner(&expected_proxy_session_id); - let same_owner_handle = app_state - .active_provider - .acquire_connection_with_grace_for_session( - &input.name, - &test_addr_with_port(55253), - false, - 0, - ConnectionKind::Normal, - Some(&expected_owner), - ) - .await; - assert!(same_owner_handle.is_some(), "reservation must be owned by input:1|hls|80510, not virtual_id=1001"); - app_state.connection_manager.release_provider_handle(same_owner_handle).await; - } - - #[tokio::test] - async fn hls_cache_entry_creates_new_lease_for_same_user_session_and_proxy_session() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - - let first_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let first_variant_uri = single_variant_uri(first_response).await; - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); - let first_access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); - let first_session_token = - access_lease_session_token(&app_state, &proxy_session_id, &first_access_lease_id).await; - - let second_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let second_variant_uri = single_variant_uri(second_response).await; - assert_ne!( - access_lease_id_from_variant_uri(&first_variant_uri), - access_lease_id_from_variant_uri(&second_variant_uri) - ); - let second_access_lease_id = - HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); - let second_session_token = - access_lease_session_token(&app_state, &proxy_session_id, &second_access_lease_id).await; - assert_ne!(first_session_token, second_session_token); - } - - #[tokio::test] - async fn hls_cache_entry_creates_new_lease_after_manifest_touch() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - - let first_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let first_variant_uri = single_variant_uri(first_response).await; - let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); - let first_session_token = access_lease_session_token(&app_state, &proxy_session_id, &first_lease_id).await; - let now_ms = super::current_time_millis(); - - assert!(matches!( - app_state - .hls_proxy - .touch_manifest_access_lease( - &first_lease_id, - &proxy_session_id, - now_ms, - None, - Some(super::HlsAccessLeasePendingDeadline::Bootstrap { - deadline_ms: now_ms.saturating_add(super::hls_pending_bootstrap_window_ms(&app_state)), - }), - super::hls_access_lease_ttl_ms(&app_state), - ) - .await, - HlsAccessLeaseTouch::Touched { .. } - )); - - let second_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let second_variant_uri = single_variant_uri(second_response).await; - - assert_ne!( - access_lease_id_from_variant_uri(&first_variant_uri), - access_lease_id_from_variant_uri(&second_variant_uri) - ); - let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); - let second_session_token = access_lease_session_token(&app_state, &proxy_session_id, &second_lease_id).await; - assert_ne!(first_session_token, second_session_token); - } - - #[tokio::test] - async fn hls_cache_entry_does_not_reuse_activated_access_lease() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - - let first_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let first_variant_uri = single_variant_uri(first_response).await; - let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); - let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); - let now_ms = super::current_time_millis(); - assert!(app_state - .hls_proxy - .activate_access_lease( - &first_lease_id, - &proxy_session_id, - now_ms, - HlsAccessLeaseTiming { - active_window_ms: 5_000, - valid_window_ms: super::hls_access_lease_ttl_ms(&app_state), - }, - ) - .await - .is_activated()); - - let second_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let second_variant_uri = single_variant_uri(second_response).await; - let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); - - assert_ne!(first_lease_id, second_lease_id); - let first_session_token = access_lease_session_token(&app_state, &proxy_session_id, &first_lease_id).await; - let second_session_token = access_lease_session_token(&app_state, &proxy_session_id, &second_lease_id).await; - assert_ne!(first_session_token, second_session_token); - assert!( - app_state - .hls_proxy - .touch_access_lease( - &first_lease_id, - super::current_time_millis(), - HlsAccessLeaseTiming { - active_window_ms: 5_000, - valid_window_ms: super::hls_access_lease_ttl_ms(&app_state), - }, - ) - .await - ); - } - - #[tokio::test] - async fn hls_cache_entry_ignores_existing_pending_lease_for_new_playback() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let proxy_session_id = build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); - let old_lease_id = HlsAccessLeaseId("old-pending-lease".to_string()); - let old_session_token = "old-hls-session-token"; - let old_issued_at_ms = super::current_time_millis().saturating_sub(6_000); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - old_lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), - proxy_session_id.clone(), - "hls-user".to_string(), - old_session_token.to_string(), - 1, - "12345".to_string(), - 12345, - old_issued_at_ms, - super::hls_access_lease_ttl_ms(&app_state), - )) - .await; - - let response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let variant_uri = single_variant_uri(response).await; - let new_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); - let new_session_token = access_lease_session_token(&app_state, &proxy_session_id, &new_lease_id).await; - - assert_ne!(old_lease_id, new_lease_id); - assert_ne!(old_session_token, new_session_token); - } - - #[tokio::test] - async fn hls_cache_parallel_real_playbacks_same_virtual_id_register_distinct_streams() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; - let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; - let origin_source = super::build_hls_origin_source(&input, "12345"); - let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; - - let first_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source.clone(), - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let first_variant_uri = single_variant_uri(first_response).await; - let first_lease_id = access_lease_id_from_variant_uri(&first_variant_uri); - assert_eq!(proxy_session_id_from_variant_uri(&first_variant_uri), proxy_session_id); - let proxy_session = ProxySessionId(proxy_session_id.clone()); - let first_session_token = - access_lease_session_token(&app_state, &proxy_session, &HlsAccessLeaseId(first_lease_id.to_string())).await; - publish_ready_test_manifest_for_lease( - &app_state, - &proxy_session, - &HlsAccessLeaseId(first_lease_id.to_string()), - 4_000, - ) - .await; - let first_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{first_lease_id}/000123.ts"); - assert_eq!(get_status(Arc::clone(&app_state), &first_segment_uri).await, StatusCode::OK); - - let second_response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 12345, - None, - None, - None, - request_url, - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - let second_variant_uri = single_variant_uri(second_response).await; - let second_lease_id = access_lease_id_from_variant_uri(&second_variant_uri); - assert_ne!(first_lease_id, second_lease_id); - assert_eq!(proxy_session_id_from_variant_uri(&second_variant_uri), proxy_session_id); - let second_session_token = - access_lease_session_token(&app_state, &proxy_session, &HlsAccessLeaseId(second_lease_id.to_string())) - .await; - assert_ne!(first_session_token, second_session_token); - publish_ready_test_manifest_for_lease( - &app_state, - &proxy_session, - &HlsAccessLeaseId(second_lease_id.to_string()), - 4_000, - ) - .await; - let second_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{second_lease_id}/000123.ts"); - assert_eq!(get_status(Arc::clone(&app_state), &second_segment_uri).await, StatusCode::OK); - - let streams = app_state.active_users.active_streams().await; - assert_eq!(streams.len(), 2); - let first_stream = streams - .iter() - .find(|stream| stream.session_token.as_deref() == Some(first_session_token.as_str())) - .expect("first stream should be registered"); - let second_stream = streams - .iter() - .find(|stream| stream.session_token.as_deref() == Some(second_session_token.as_str())) - .expect("second stream should be registered"); - let shared_stream_id = super::hls_cache_shared_stream_id(&proxy_session); - assert_ne!(first_stream.session_token, second_stream.session_token); - assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(second_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); - assert_eq!(second_stream.channel.shared_joined_existing, Some(true)); - } - - #[tokio::test] - async fn hls_cache_entry_master_playlist_for_xtream_uses_stream_ref_session_identity() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = ConfigInput { - id: 7, - name: Arc::from("xtream-input"), - input_type: InputType::Xtream, - ..ConfigInput::default() - }; - let origin_source = super::build_hls_origin_source(&input, "80510"); - let expected_proxy_session_id = - build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); - - let response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 80510, - None, - None, - None, - "http://origin.example.com/live/user/pass/80510.m3u8", - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - None, - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - let variant_uri = single_variant_uri(response).await; - assert!(variant_uri.starts_with(&format!("/hls/shared/live/{}/", expected_proxy_session_id.0))); - assert!(variant_uri.ends_with("/manifest.m3u8")); - assert!(app_state.hls_proxy.sessions().get_by_key(&HlsSessionKey::new(7, "80510")).await.is_none()); - assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); - } - - #[tokio::test] - async fn hls_cache_entry_master_playlist_for_m3u_uses_stream_ref_session_identity() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let mut user = ProxyUserCredentials::default(); - user.username = "hls-user".to_string(); - let input = - ConfigInput { id: 9, name: Arc::from("m3u-input"), input_type: InputType::M3u, ..ConfigInput::default() }; - let origin_source = super::build_hls_origin_source(&input, "70001"); - let expected_proxy_session_id = - build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); - - let response = super::create_hls_cache_entry_master_playlist_response( - &app_state, - &test_fingerprint(), - &user, - origin_source, - 70001, - None, - None, - None, - "http://media.example.com/channel/playlist.m3u8", - &input, - UserConnectionPermission::Allowed, - Some(ConnectionKind::Normal), - Some("/iptv"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - let variant_uri = single_variant_uri(response).await; - assert!(variant_uri.starts_with(&format!("/iptv/hls/shared/live/{}/", expected_proxy_session_id.0))); - assert!(variant_uri.ends_with("/manifest.m3u8")); - assert!(app_state.hls_proxy.sessions().get_by_key(&HlsSessionKey::new(9, "70001")).await.is_none()); - assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); - } - - #[tokio::test] - async fn hls_proxy_manifest_invalid_token_starts_no_origin_work() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - - let response = get_response( - Arc::clone(&app_state), - "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/not-a-valid-token/manifest.m3u8", - None, - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); - assert!(app_state.hls_proxy.sessions().is_empty().await); - } - - async fn prepare_server_path_manifest_session(app_state: &Arc) -> (HlsSessionHandle, ProxySessionId) { - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = { - let mut session = session.write().await; - session.origin_refresh.next_fetch_allowed_at_ms = u64::MAX; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - let map_id = ProxyMapId(0); - let mut map = MapEntry::new( - &session.proxy_session_id, - map_id, - OriginMapKey { - origin_epoch: 0, - resolved_origin_uri: "http://origin.example.com/init.mp4".to_string(), - byte_range: None, - }, - "mp4".to_string(), - ); - map.status = MapCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }; - session.maps.insert(map_id, map); - let mut segment = test_segment_entry( - &session.proxy_session_id, - 123, - SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, - ); - segment.map_ref = Some(map_id); - session.segments.insert(123, segment); - session.advance_media_readiness_generation(); - session.last_rendered_manifest = Some(RenderedManifest { - body: format!( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXT-X-MAP:URI=\"/hls/shared/live/{proxy_session_id}/{}/map/000000.mp4\"\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_id}/{}/000123.ts\n", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ), - first_proxy_seq: 123, - last_proxy_seq: 123, - playlist_duration_ms: 4_000, - valid_until_ms: rendered_at_ms.saturating_add(4_000), - render_gap_segments: 0, - rendered_at_ms, - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![123], - }); - record_test_normal_manifest_commit(&mut session, rendered_at_ms); - ProxySessionId(proxy_session_id) - }; - (session, proxy_session_id) - } - - #[tokio::test] - async fn hls_cache_manifest_response_applies_current_users_server_path_without_mutating_session_body() { - let input_name = Arc::::from("test-input"); - let input = ConfigInput { - id: 1, - name: Arc::clone(&input_name), - input_type: InputType::Xtream, - url: "http://origin.example.com".to_string(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - max_connections: 1, - enabled: true, - ..ConfigInput::default() - }; - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - enable_hls_cache(&app_state); - create_active_hls_user_session(&app_state).await; - let (session, proxy_session_id) = prepare_server_path_manifest_session(&app_state).await; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); - prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; - - let response = super::try_hls_cache_canonical_manifest_response( - &app_state, - &test_fingerprint(), - &access_context, - &proxy_session_id, - &access_lease_id, - HlsAccessLeaseState::Pending, - super::HlsCacheManifestOrigin { - raw_request_url: "http://origin.example.com/live/user/pass/12345.m3u8", - session_entry_url: super::HlsOriginEntryUrl::direct_http( - "http://origin.example.com/live/user/pass/12345.m3u8", - ), - input: &input, - origin_source: super::build_hls_origin_source(&input, "12345"), - }, - HeaderMap::new(), - Some("/iptv"), - "/live/hls-user/hls-pass/12345.m3u8", - super::HlsManifestRefreshOrdering::Background, - ) - .await - .expect("hls cache should handle valid live hls entrypoint"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); - - assert!(body.contains(&format!("/iptv/hls/shared/live/{}/", proxy_session_id.0))); - assert!(body.contains("/map/000000.mp4")); - assert!(body.contains("/000123.ts")); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - let stored_body = session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body.clone(); - assert!(stored_body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert!(stored_body.contains(&format!("/hls/shared/live/{}/", proxy_session_id.0))); - assert!(!stored_body.contains("/iptv/hls/shared/live/")); - assert!(!stored_body.contains(&access_lease_id.0)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - } - - fn transient_manifest_body(proxy_session_id: &str) -> String { - transient_manifest_body_from_sequence(proxy_session_id, 100, 6) - } - - fn transient_manifest_body_from_sequence(proxy_session_id: &str, first_sequence: u64, count: usize) -> String { - let mut body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); - for index in 0..count { - let sequence = first_sequence.saturating_add(u64::try_from(index).expect("test sequence index fits u64")); - body.push_str("#EXTINF:10.0,\n"); - let _ = writeln!( - body, - "/hls/shared/live/{proxy_session_id}/{}/r/seg{sequence}.ts", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ); - } - body - } - - fn media_uri_count(body: &str) -> usize { - body.lines().filter(|line| !line.is_empty() && !line.starts_with('#')).count() - } - - fn normal_manifest_body(proxy_session_id: &str) -> String { - normal_manifest_body_from_sequence(proxy_session_id, 0, 6) - } - - fn normal_manifest_body_from_sequence(proxy_session_id: &str, first_sequence: u64, count: usize) -> String { - let mut body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); - for index in 0..count { - let sequence = first_sequence.saturating_add(u64::try_from(index).expect("test sequence index fits u64")); - body.push_str("#EXTINF:10.0,\n"); - let _ = writeln!( - body, - "/hls/shared/live/{proxy_session_id}/{}/{sequence:06}.ts", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ); - } - body - } - - fn store_normal_manifest_body(session: &mut HlsSession, body: String, rendered_at_ms: u64) { - store_normal_manifest_body_range(session, body, 0, 6, rendered_at_ms); - } - - fn record_test_normal_manifest_commit(session: &mut HlsSession, rendered_at_ms: u64) { - let identity = session - .next_manifest_commit_identity(rendered_at_ms) - .expect("test manifest commit generation remains available"); - session.record_normal_manifest_commit_identity(identity); - } - - fn store_normal_manifest_body_range( - session: &mut HlsSession, - body: String, - first_proxy_seq: u64, - count: usize, - rendered_at_ms: u64, - ) { - let last_proxy_seq = - first_proxy_seq.saturating_add(u64::try_from(count.saturating_sub(1)).expect("test count fits u64")); - let proxy_session_id = session.proxy_session_id.clone(); - for proxy_seq in first_proxy_seq..=last_proxy_seq { - let mut entry = test_segment_entry( - &proxy_session_id, - proxy_seq, - SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, - ); - entry.duration_ms = 10_000; - session.segments.insert(proxy_seq, entry); - } - session.advance_media_readiness_generation(); - session.last_rendered_manifest = Some(RenderedManifest { - body, - first_proxy_seq, - last_proxy_seq, - playlist_duration_ms: 60_000, - valid_until_ms: rendered_at_ms.saturating_add(60_000), - render_gap_segments: 0, - rendered_at_ms, - discontinuity_sequence: 0, - target_duration_ms: 10_000, - segment_proxy_seqs: (first_proxy_seq..=last_proxy_seq).collect(), - }); - record_test_normal_manifest_commit(session, rendered_at_ms); - } - - async fn try_test_hls_cached_manifest_response( - app_state: &Arc, - session: &HlsSessionHandle, - access_lease_id: &HlsAccessLeaseId, - access_lease_state: HlsAccessLeaseState, - strip: &StripConfig, - server_path: Option<&str>, - options: super::HlsCachedManifestOptions, - ) -> Option { - let proxy_session_id = session.read().await.proxy_session_id.clone(); - let now_ms = super::current_time_millis(); - if app_state - .hls_proxy - .access_lease_response_snapshot(access_lease_id, &proxy_session_id, now_ms) - .await - .is_none() - { - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - access_lease_id.clone(), - HlsPlaybackFamilyKey::new("test-user", "manifest-test-client"), - proxy_session_id, - "test-user".to_string(), - "manifest-test-session".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - 60_000, - )) - .await; - } - super::try_hls_cached_manifest_response( - app_state, - session, - access_lease_id, - access_lease_state, - strip, - server_path, - options, - super::HlsRuntimeBandwidthLearningContext::Disabled, - ) - .await - } - - #[test] - fn repeated_speculative_strip_candidates_yield_one_committed_applied_diagnostic() { - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let body = normal_manifest_body("proxy-session"); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - let candidates = (0..8) - .map(|_| { - super::materialize_shared_hls_access_manifest( - &body, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - super::HlsManifestWindowPolicy::ApplyLiveWindow, - "normal", - None, - ) - }) - .collect::>(); - - assert_eq!(candidates.len(), 8); - let candidate_count = candidates.len(); - let diagnostics = candidates - .iter() - .enumerate() - .filter_map(|(index, candidate)| { - super::hls_initial_strip_publication_diagnostic( - if index.saturating_add(1) == candidate_count { - super::HlsInitialStripPublicationStatus::Committed - } else { - super::HlsInitialStripPublicationStatus::NotCommitted - }, - HlsAccessLeaseState::Pending, - candidate, - ) - }) - .collect::>(); - - assert_eq!( - diagnostics, - vec![super::HlsInitialStripPublicationDiagnostic::Applied { - mode: "normal", - strip_mode: "segments", - configured: 3, - effective: 3, - visible_segments: 3, - }] - ); - } - - #[test] - fn committed_pending_strip_disabled_yields_one_skipped_diagnostic() { - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let materialized = super::materialize_shared_hls_access_manifest( - &normal_manifest_body("proxy-session"), - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 0 }, - super::HlsManifestWindowPolicy::ApplyLiveWindow, - "normal", - None, - ); - - let diagnostics = [super::hls_initial_strip_publication_diagnostic( - super::HlsInitialStripPublicationStatus::Committed, - HlsAccessLeaseState::Pending, - &materialized, - ) - .expect("committed strip diagnostic")]; - - assert_eq!( - diagnostics, - [super::HlsInitialStripPublicationDiagnostic::Skipped { - mode: "normal", - reason: crate::api::model::hls_cache::initial_strip::HlsInitialStripSkipReason::StripDisabled, - visible_segments: 6, - }] - ); - assert_eq!(media_uri_count(&materialized.body), 6); - } - - #[test] - fn pending_playlist_type_manifest_ignores_three_to_six_window_and_strip() { - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - for segment_count in [2, 8] { - let body = transient_manifest_body_from_sequence("proxy-session", 100, segment_count).replacen( - "#EXTM3U\n", - "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT\n", - 1, - ); - let materialized = super::materialize_shared_hls_access_manifest( - &body, - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 5 }, - super::HlsManifestWindowPolicy::PreserveFullManifest, - "transient", - None, - ); - - assert_eq!(media_uri_count(&materialized.body), segment_count); - assert!(materialized.body.contains("#EXT-X-PLAYLIST-TYPE:EVENT")); - assert!(!materialized.body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert_eq!( - materialized.initial_strip_outcome, - Some(super::HlsInitialStripOutcome::Skipped { - reason: super::HlsInitialStripSkipReason::ManifestSemanticsPreserveFullManifest, - visible_segments: segment_count, - }) - ); - } - } - - #[test] - fn pending_endlist_only_manifest_keeps_complete_body_despite_strip() { - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let mut body = transient_manifest_body_from_sequence("proxy-session", 100, 8); - body.push_str("#EXT-X-ENDLIST\n"); - let window_policy = - crate::processing::parser::hls::origin_manifest::parse_manifest_semantics(&body).window_policy(); - assert_eq!(window_policy, super::HlsManifestWindowPolicy::PreserveFullManifest); - - let materialized = super::materialize_shared_hls_access_manifest( - &body, - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 5 }, - window_policy, - "transient", - None, - ); - - assert_eq!(media_uri_count(&materialized.body), 8); - assert!(materialized.body.contains("#EXT-X-ENDLIST")); - assert_eq!( - materialized.initial_strip_outcome, - Some(super::HlsInitialStripOutcome::Skipped { - reason: super::HlsInitialStripSkipReason::ManifestSemanticsPreserveFullManifest, - visible_segments: 8, - }) - ); - } - - #[test] - fn committed_activated_manifest_yields_one_lease_state_skip_diagnostic() { - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let materialized = super::materialize_shared_hls_access_manifest( - &normal_manifest_body("proxy-session"), - &access_lease_id, - HlsAccessLeaseState::Activated, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - super::HlsManifestWindowPolicy::ApplyLiveWindow, - "normal", - None, - ); - - let diagnostics = [super::hls_initial_strip_publication_diagnostic( - super::HlsInitialStripPublicationStatus::Committed, - HlsAccessLeaseState::Activated, - &materialized, - ) - .expect("committed strip diagnostic")]; - - assert_eq!( - diagnostics, - [super::HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { - mode: "normal", - reason: super::HlsInitialStripLeaseSkipReason::LeaseActivated, - }] - ); - assert_eq!(media_uri_count(&materialized.body), 6); - } - - #[tokio::test(start_paused = true)] - async fn pending_strip_admission_timeout_does_not_commit_speculative_candidate() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.clone(); - let rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id.0), rendered_at_ms); - session.segments.get_mut(&1).expect("visible test segment").status = SegmentCacheStatus::Discovered; - session.advance_media_readiness_generation(); - session.mark_authorized_media_access(rendered_at_ms); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 3 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::from_millis(75)), - ) - .await - .expect("timeout response"); - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) - .await - .expect("pending lease remains available"); - assert!(lease.last_manifest_snapshot.is_none()); - } - - async fn publish_ready_test_manifest_for_lease( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - target_duration_ms: u64, - ) { - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(proxy_session_id) - .await - .expect("test session exists"); - let (proxy_seq, duration_ms) = { - let session = session.read().await; - session - .segments - .iter() - .find_map(|(proxy_seq, entry)| { - matches!(entry.status, SegmentCacheStatus::Ready { .. }).then_some((*proxy_seq, entry.duration_ms)) - }) - .expect("READY test segment") - }; - let now_ms = super::current_time_millis(); - let publication_guard = app_state - .hls_proxy - .prepare_access_lease_manifest_publication(access_lease_id, proxy_session_id, now_ms) - .await - .expect("test lease accepts publication"); - let snapshot = HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, - source_commit_identity: HlsManifestCommitIdentity::new(now_ms), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 0, - delivered_at_ms: now_ms, - first_proxy_seq: proxy_seq, - last_proxy_seq: proxy_seq, - visible_segments: Arc::from([HlsLeaseManifestSegment { - proxy_seq, - duration_ms, - uri: format!("/hls/shared/live/{}/{}/{proxy_seq:06}.ts", proxy_session_id.0, access_lease_id.0).into(), - discontinuity_before: false, - map_ref_ready: true, - encryption: None, - }]), - discontinuity_sequence: 0, - target_duration_ms: target_duration_ms.max(duration_ms), - playlist_duration_ms: duration_ms, - last_visible_media_end_ms: duration_ms, - active_map: None, - active_encryption: None, - container: HlsMediaContainer::MpegTs, - }; - assert!(app_state - .hls_proxy - .commit_access_lease_manifest_publication( - access_lease_id, - proxy_session_id, - publication_guard, - snapshot, - now_ms, - ) - .await - .is_committed()); - } - - async fn prepare_user_exhausted_terminal_bundle(app_state: &Arc, target_duration_ms: u64) { - let response = app_state.app_config.custom_stream_response.load_full().expect("runtime custom responses"); - let asset = response - .user_connections_exhausted - .as_ref() - .and_then(|buffer| snapshot_terminal_media_asset(buffer).ok()) - .expect("valid user-exhausted terminal asset"); - let key = prepared_terminal_bundle_key(&asset, target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); - let state = app_state.hls_proxy.start_prepared_terminal_bundle( - asset, - target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let state = match state { - HlsPreparedTerminalBundleState::Preparing { .. } => app_state - .hls_proxy - .wait_for_prepared_terminal_bundle(key) - .await - .expect("user-exhausted terminal bundle completion"), - state => state, - }; - assert!(matches!( - state, - HlsPreparedTerminalBundleState::Ready { ref bundle } if bundle.key == key - )); - } - - struct RuntimePolicyEndpointFixture { - _temp_dir: tempfile::TempDir, - app_state: Arc, - proxy_session_id: ProxySessionId, - lease_id: HlsAccessLeaseId, - manifest_uri: String, - live_segment_uri: String, - } - - async fn assert_runtime_policy_base_timing( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - phase: &str, - ) { - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(proxy_session_id) - .await - .expect("runtime policy session"); - let manifest = app_state - .hls_proxy - .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) - .await - .and_then(|lease| lease.last_manifest_snapshot) - .expect("runtime policy manifest"); - let evidence = prepare_terminal_base_evidence( - &session, - app_state.hls_proxy.segment_cache(), - &manifest, - super::current_time_millis(), - ) - .await; - assert!( - evidence.timing().is_some(), - "runtime policy base timing {phase}: {}", - evidence.track_evidence_reason_code() - ); - evidence.release(); - } - - async fn serve_and_wait_runtime_policy_base_segment( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - live_segment_uri: &str, - ) { - let initial_segment = get_response(Arc::clone(app_state), live_segment_uri, None).await; - assert_eq!(initial_segment.status(), StatusCode::OK); - assert!(!response_body(initial_segment).await.is_empty()); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - let completed = app_state - .hls_proxy - .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) - .await - .and_then(|lease| lease.playback_cursor.highest_contiguous_completed_proxy_seq); - if completed == Some(123) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("initial live segment completion"); - } - - async fn runtime_policy_endpoint_fixture(publish_manifest: bool) -> RuntimePolicyEndpointFixture { - const TARGET_DURATION_MS: u64 = 12_000; - - let temp_dir = tempfile::tempdir().expect("runtime policy cache tempdir"); - let app_state = - test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); - enable_runtime_policy_custom_responses(&app_state); - let live_bytes = - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")); - let proxy_session_id = ProxySessionId(map_ready_segment_without_lease(&app_state, 123, "ts", live_bytes).await); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("runtime policy session"); - session.write().await.segments.get_mut(&123).expect("runtime policy base segment").duration_ms = - TARGET_DURATION_MS; - let lease_id = HlsAccessLeaseId(grant_hls_proxy_lease(&app_state, &proxy_session_id.0).await); - if publish_manifest { - publish_ready_test_manifest_for_lease(&app_state, &proxy_session_id, &lease_id, TARGET_DURATION_MS).await; - let target_duration_ms = app_state - .hls_proxy - .access_lease_response_snapshot(&lease_id, &proxy_session_id, super::current_time_millis()) - .await - .and_then(|lease| lease.last_manifest_snapshot) - .map(|manifest| manifest.target_duration_ms) - .expect("published runtime policy target duration"); - prepare_user_exhausted_terminal_bundle(&app_state, target_duration_ms).await; - } - let manifest_uri = format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, lease_id.0); - let live_segment_uri = format!("/hls/shared/live/{}/{}/000123.ts", proxy_session_id.0, lease_id.0); - if publish_manifest { - assert_runtime_policy_base_timing(&app_state, &proxy_session_id, &lease_id, "before serve").await; - } - serve_and_wait_runtime_policy_base_segment(&app_state, &proxy_session_id, &lease_id, &live_segment_uri).await; - if publish_manifest { - assert_runtime_policy_base_timing(&app_state, &proxy_session_id, &lease_id, "after serve").await; - } - - RuntimePolicyEndpointFixture { - _temp_dir: temp_dir, - app_state, - proxy_session_id, - lease_id, - manifest_uri, - live_segment_uri, - } - } - - async fn wait_for_runtime_policy_terminal_plan(fixture: &RuntimePolicyEndpointFixture) -> Arc { - let plan = tokio::time::timeout(Duration::from_secs(10), async { - loop { - if let Some(HlsLeasePlaybackMode::TerminalTail(plan)) = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot( - &fixture.lease_id, - &fixture.proxy_session_id, - super::current_time_millis(), - ) - .await - .map(|lease| lease.playback_mode) - { - return plan; - } - tokio::task::yield_now().await; - } - }) - .await; - if let Ok(plan) = plan { - return plan; - } - let lease = fixture - .app_state - .hls_proxy - .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) - .await; - let state = lease.as_ref().map_or("missing", |lease| lease.state.as_log_value()); - let playback = lease.as_ref().map_or("missing", |lease| match lease.playback_mode { - HlsLeasePlaybackMode::Live => "live", - HlsLeasePlaybackMode::TerminalTail(_) => "terminal-tail", - HlsLeasePlaybackMode::TerminalUnavailable { .. } => "terminal-unavailable", - HlsLeasePlaybackMode::Ended => "ended", - }); - panic!( - "runtime policy terminal owner deadline: state={state} playback={playback} owners={}", - fixture.app_state.hls_proxy.terminal_pending().owner_count() - ); - } - - #[tokio::test] - async fn hls_cache_pending_normal_manifest_applies_initial_strip_without_mutating_shared_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); - session.mark_authorized_media_access(rendered_at_ms); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - let stored_before = session.read().await.last_rendered_manifest.as_ref().expect("normal manifest").body.clone(); - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("normal manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); - assert!(body.contains("/000000.ts")); - assert!(body.contains("/000001.ts")); - assert!(body.contains("/000002.ts")); - assert!(!body.contains("/000003.ts")); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert_eq!(session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body, stored_before); - assert_eq!(media_uri_count(&stored_before), 6); - } - - #[tokio::test] - async fn hls_cache_idle_normal_manifest_applies_initial_strip_without_mutating_shared_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); - session.mark_authorized_media_access(rendered_at_ms); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - let stored_before = session.read().await.last_rendered_manifest.as_ref().expect("normal manifest").body.clone(); - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Idle, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("idle normal manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); - assert!(body.contains("/000000.ts")); - assert!(body.contains("/000001.ts")); - assert!(body.contains("/000002.ts")); - assert!(!body.contains("/000003.ts")); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert_eq!(session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body, stored_before); - assert_eq!(media_uri_count(&stored_before), 6); - } - - #[tokio::test] - async fn hls_cache_activated_normal_manifest_skips_initial_strip() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); - session.mark_authorized_media_access(rendered_at_ms); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("normal manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 6); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - } - - #[tokio::test] - async fn hls_cache_fresh_required_normal_manifest_does_not_serve_stale_committed_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let rendered_at_ms = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); - session.mark_authorized_media_access(rendered_at_ms); - rendered_at_ms - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::ZERO).requiring_newer_manifest(rendered_at_ms), - ) - .await; - - assert!(response.is_none()); - } - - #[tokio::test] - async fn hls_cache_fresh_required_normal_manifest_waits_for_newer_commit() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let (proxy_session_id, old_rendered_at_ms) = { - let mut session = session.write().await; - let proxy_session_id = session.proxy_session_id.clone(); - let old_rendered_at_ms = super::current_time_millis(); - store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id.0), old_rendered_at_ms); - session.origin_refresh.in_flight = true; - session.mark_authorized_media_access(old_rendered_at_ms); - (proxy_session_id, old_rendered_at_ms) - }; - let session_for_commit = Arc::clone(&session); - let proxy_session_for_body = proxy_session_id.0.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(20)).await; - let mut session = session_for_commit.write().await; - let rendered_at_ms = super::current_time_millis(); - let mut entry = test_segment_entry( - &session.proxy_session_id, - 100, - SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, - ); - entry.duration_ms = 4_000; - session.segments.insert(100, entry); - session.advance_media_readiness_generation(); - session.last_rendered_manifest = Some(RenderedManifest { - body: format!( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_for_body}/{}/000100.ts\n", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ), - first_proxy_seq: 100, - last_proxy_seq: 100, - playlist_duration_ms: 4_000, - valid_until_ms: rendered_at_ms.saturating_add(4_000), - render_gap_segments: 0, - rendered_at_ms, - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![100], - }); - record_test_normal_manifest_commit(&mut session, rendered_at_ms); - session.origin_refresh.in_flight = false; - }); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::from_millis(200)) - .requiring_newer_manifest(old_rendered_at_ms), - ) - .await - .expect("fresh manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert!(body.contains("000100.ts")); - assert!(!body.contains("000000.ts")); - assert!(body.contains(&access_lease_id.0)); - } - - #[tokio::test] - async fn hls_cache_pending_transient_manifest_applies_initial_strip_without_mutating_shared_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); - session.mark_authorized_media_access(super::current_time_millis()); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - let stored_before = session.read().await.transient.last_manifest_body.clone().expect("transient manifest body"); - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - assert_eq!( - session.read().await.transient.last_manifest_body.as_ref().expect("stored manifest"), - &stored_before - ); - assert_eq!(media_uri_count(&stored_before), 6); - } - - fn assert_encrypted_transient_terminal_incompatibility(snapshot: HlsLeaseManifestSnapshot, now_ms: u64) { - let asset = terminal_test_asset(); - let bundle_target_duration_ms = snapshot.target_duration_ms.max(asset.duration_ms()); - let base_timing = Some(HlsTerminalTailBuildInput::base_timing_for_test(&asset, &snapshot)); - let base_splice_evidence = Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test(&asset)); - let terminal_splice_evidence = base_splice_evidence.clone(); - assert_eq!( - build_terminal_tail_plan(HlsTerminalTailBuildInput { - generation: HlsTerminalTailGeneration(1), - created_at_ms: now_ms, - base_availability: Arc::from([]), - base_track_signature: Some(asset.track_signature().clone()), - base_splice_evidence, - terminal_splice_evidence, - base_timing, - base_key_bindings: Arc::from([]), - expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable( - HlsTerminalAssetIdentity::from_asset(&asset), - ), - anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test(&asset, bundle_target_duration_ms,), - base_manifest: snapshot, - asset, - }), - Err(HlsTerminalTailCompatibility::TransientPassthroughUnsupported) - ); - } - - #[tokio::test] - async fn encrypted_transient_endpoint_stores_client_visible_key_and_typed_terminal_incompatibility() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let now_ms = super::current_time_millis(); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), now_ms) - .await; - let (proxy_session_id, access_lease_id) = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.clone(); - let access_lease_id = HlsAccessLeaseId("encrypted-access-lease".to_string()); - let body = format!( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:20\n\ - #EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/{}/{}/r/key.bin\",IV=0x1,KEYFORMAT=\"identity\",KEYFORMATVERSIONS=\"1\"\n\ - #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/20.ts\n\ - #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/21.ts\n\ - #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/22.ts\n", - proxy_session_id.0, - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, - proxy_session_id.0, - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, - proxy_session_id.0, - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, - proxy_session_id.0, - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, - ); - session.transient.replace_manifest_with_semantics(body, now_ms, Some(12_000)); - session.mark_authorized_media_access(now_ms); - (proxy_session_id, access_lease_id) - }; - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - access_lease_id.clone(), - HlsPlaybackFamilyKey::new("hls-user", "encrypted-client"), - proxy_session_id.clone(), - "hls-user".to_string(), - "encrypted-session".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - 60_000, - )) - .await; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &StripConfig { mode: HlsStripMode::Segments, value: 0 }, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("encrypted transient response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - let lease = app_state - .hls_proxy - .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms) - .await - .expect("lease snapshot"); - let snapshot = lease.last_manifest_snapshot.expect("manifest snapshot"); - let encryption = snapshot.active_encryption.as_ref().expect("active encryption"); - - assert!( - body.contains(&format!("URI=\"/hls/shared/live/{}/{}/r/key.bin\"", proxy_session_id.0, access_lease_id.0)) - ); - assert_eq!(snapshot.delivery_mode, HlsManifestDeliveryMode::TransientPassthrough); - assert_eq!(encryption.method, "AES-128"); - assert_eq!(encryption.iv.as_deref(), Some("0x1")); - assert_eq!(encryption.key_format.as_deref(), Some("identity")); - assert_eq!(encryption.key_format_versions.as_deref(), Some("1")); - assert!(encryption.can_reset_to_clear); - - assert_encrypted_transient_terminal_incompatibility(snapshot, now_ms); - } - - #[tokio::test] - async fn hls_cache_idle_transient_manifest_applies_initial_strip_without_mutating_shared_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); - session.mark_authorized_media_access(super::current_time_millis()); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - let stored_before = session.read().await.transient.last_manifest_body.clone().expect("transient manifest body"); - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Idle, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("idle transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - assert_eq!( - session.read().await.transient.last_manifest_body.as_ref().expect("stored manifest"), - &stored_before - ); - assert_eq!(media_uri_count(&stored_before), 6); - } - - #[tokio::test] - async fn hls_cache_activated_transient_manifest_skips_initial_strip() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); - session.mark_authorized_media_access(super::current_time_millis().saturating_sub(16_000)); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await - .expect("transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 6); - assert!(body.contains(&access_lease_id.0)); - assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); - } - - #[tokio::test] - async fn hls_cache_transient_manifest_without_media_activity_is_not_served_from_committed_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_id), - rendered_at_ms, - Some(60_000), - ); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await; - - assert!(response.is_none()); - } - - #[tokio::test] - async fn hls_cache_no_media_yet_transient_manifest_is_served_for_initial_canonical_response() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - let rendered_at_ms = super::current_time_millis(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_id), - rendered_at_ms, - Some(60_000), - ); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::ZERO), - ) - .await - .expect("initial transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - } - - #[tokio::test] - async fn hls_cache_transient_manifest_outside_soft_window_is_not_served_from_committed_body() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.0.clone(); - session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); - session.mark_authorized_media_access(super::current_time_millis().saturating_sub(60_000)); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Activated, - &strip, - None, - super::HlsCachedManifestOptions::committed_only(Duration::ZERO), - ) - .await; - - assert!(response.is_none()); - } - - #[tokio::test] - async fn hls_cache_expired_transient_manifest_with_active_binding_is_served_while_manifest_valid() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let now_ms = super::current_time_millis(); - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.clone(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_id.0), - now_ms.saturating_sub(1_000), - Some(60_000), - ); - session.mark_authorized_media_access(now_ms.saturating_sub(60_000)); - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::from("test-input"), - Arc::from("test-account"), - &proxy_session_id, - now_ms, - )); - session.origin_refresh.in_flight = true; - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), - ) - .await - .expect("valid committed transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - } - - #[tokio::test] - async fn hls_cache_expired_transient_manifest_with_active_binding_is_not_served_after_manifest_validity() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let now_ms = super::current_time_millis(); - let _proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - let proxy_session_id = session.proxy_session_id.clone(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_id.0), - now_ms.saturating_sub(60_000), - Some(1_000), - ); - session.mark_authorized_media_access(now_ms.saturating_sub(60_000)); - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::from("test-input"), - Arc::from("test-account"), - &proxy_session_id, - now_ms, - )); - proxy_session_id - }; - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::ZERO), - ) - .await; - - assert!(response.is_none()); - } - - #[tokio::test] - async fn hls_cache_no_media_yet_waits_for_first_normal_manifest_commit() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = { - let mut session = session.write().await; - session.origin_refresh.in_flight = true; - session.proxy_session_id.clone() - }; - let session_for_commit = Arc::clone(&session); - let proxy_session_for_body = proxy_session_id.0.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(20)).await; - let mut session = session_for_commit.write().await; - let rendered_at_ms = super::current_time_millis(); - let mut entry = test_segment_entry( - &session.proxy_session_id, - 100, - SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, - ); - entry.duration_ms = 4_000; - session.segments.insert(100, entry); - session.advance_media_readiness_generation(); - session.last_rendered_manifest = Some(RenderedManifest { - body: format!( - "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_for_body}/{}/000100.ts\n", - crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER - ), - first_proxy_seq: 100, - last_proxy_seq: 100, - playlist_duration_ms: 4_000, - valid_until_ms: rendered_at_ms.saturating_add(4_000), - render_gap_segments: 0, - rendered_at_ms, - discontinuity_sequence: 0, - target_duration_ms: 4_000, - segment_proxy_seqs: vec![100], - }); - record_test_normal_manifest_commit(&mut session, rendered_at_ms); - session.origin_refresh.in_flight = false; - }); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), - ) - .await - .expect("initial normal manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert!(body.contains(&access_lease_id.0)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - } - - #[tokio::test] - async fn hls_cache_no_media_yet_waits_for_first_transient_manifest_commit() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - session.origin_refresh.in_flight = true; - session.proxy_session_id.clone() - }; - let session_for_commit = Arc::clone(&session); - let proxy_session_for_body = proxy_session_id.0.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(20)).await; - let mut session = session_for_commit.write().await; - let rendered_at_ms = super::current_time_millis(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_for_body), - rendered_at_ms, - Some(60_000), - ); - session.origin_refresh.in_flight = false; - }); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), - ) - .await - .expect("initial transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - } - - #[tokio::test] - async fn hls_cache_expired_transient_manifest_waits_for_revalidation_commit() { - let app_state = test_app_state(); - enable_hls_cache(&app_state); - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - session.mark_authorized_media_access(super::current_time_millis().saturating_sub(60_000)); - session.origin_refresh.in_flight = true; - session.proxy_session_id.clone() - }; - let session_for_commit = Arc::clone(&session); - let proxy_session_for_body = proxy_session_id.0.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(20)).await; - let mut session = session_for_commit.write().await; - let rendered_at_ms = super::current_time_millis(); - session.transient.replace_manifest_with_semantics( - transient_manifest_body(&proxy_session_for_body), - rendered_at_ms, - Some(60_000), - ); - session.origin_refresh.in_flight = false; - }); - let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); - let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; - - let response = try_test_hls_cached_manifest_response( - &app_state, - &session, - &access_lease_id, - HlsAccessLeaseState::Pending, - &strip, - None, - super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), - ) - .await - .expect("revalidated transient manifest response"); - let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); - - assert_eq!(media_uri_count(&body), 3); - assert!(body.contains(&access_lease_id.0)); - assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); - } - - async fn grant_hls_proxy_lease(app_state: &Arc, proxy_session_id: &str) -> String { - create_active_hls_user_session(app_state).await; - let now_ms = super::current_time_millis(); - let lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); - let family_key = HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key); - app_state - .hls_proxy - .prepare_access_lease(HlsAccessLease::pending( - lease_id.clone(), - family_key, - ProxySessionId(proxy_session_id.to_string()), - "hls-user".to_string(), - "hls-session-token".to_string(), - 1, - "12345".to_string(), - 12345, - now_ms, - super::hls_access_lease_ttl_ms(app_state), - )) - .await; - assert!(app_state - .hls_proxy - .activate_access_lease( - &lease_id, - &ProxySessionId(proxy_session_id.to_string()), - now_ms, - HlsAccessLeaseTiming { - active_window_ms: 5_000, - valid_window_ms: super::hls_access_lease_ttl_ms(app_state), - }, - ) - .await - .is_activated()); - lease_id.0 - } - - async fn hls_proxy_uri(app_state: &Arc, proxy_session_id: &str, suffix: &str) -> String { - let access_lease_id = grant_hls_proxy_lease(app_state, proxy_session_id).await; - let uri = format!("/hls/shared/live/{proxy_session_id}/{access_lease_id}/{suffix}"); - if suffix.starts_with("r/") { - publish_test_transient_resource_membership(app_state, proxy_session_id, &access_lease_id, &uri).await; - } - uri - } - - async fn publish_test_transient_resource_membership( - app_state: &Arc, - proxy_session_id: &str, - access_lease_id: &str, - resource_uri: &str, - ) { - let proxy_session_id = ProxySessionId(proxy_session_id.to_string()); - let access_lease_id = HlsAccessLeaseId(access_lease_id.to_string()); - let now_ms = super::current_time_millis(); - let publication = app_state - .hls_proxy - .prepare_access_lease_manifest_publication(&access_lease_id, &proxy_session_id, now_ms) - .await - .expect("test resource lease accepts manifest publication"); - let snapshot = HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::TransientPassthrough, - source_commit_identity: HlsManifestCommitIdentity::new(now_ms), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 0, - delivered_at_ms: now_ms, - first_proxy_seq: 0, - last_proxy_seq: 0, - visible_segments: Arc::from([]), - discontinuity_sequence: 0, - target_duration_ms: 4_000, - playlist_duration_ms: 0, - last_visible_media_end_ms: 0, - active_map: None, - active_encryption: None, - container: HlsMediaContainer::MpegTs, - }; - let published_resource_ids = HlsPublishedTransientResourceIds::from_manifest_body(resource_uri); - assert!(app_state - .hls_proxy - .commit_access_lease_manifest_publication_with_resources( - &access_lease_id, - &proxy_session_id, - publication, - snapshot, - published_resource_ids, - now_ms, - ) - .await - .is_committed()); - } - - fn regression_origin_manifest(first_sequence: u64, segment_count: usize) -> Vec { - let mut manifest = - format!("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); - for offset in 0..segment_count { - let sequence = first_sequence.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX)); - let _ = writeln!(&mut manifest, "#EXTINF:12.0,\n{sequence}.ts"); - } - manifest.into_bytes() - } - - fn regression_origin_refresh_request( - app_state: &Arc, - session: HlsSessionHandle, - manifest_url: &str, - access_lease_id: Option, - ) -> OriginRefreshRequest { - OriginRefreshRequest { - app_config: Arc::clone(&app_state.app_config), - session, - origin_entry: LiveHlsOriginEntry::parse(manifest_url).expect("regression origin entry"), - headers: HeaderMap::new(), - origin_provider_session_headers: HeaderMap::new(), - client: app_state.http_client.load().as_ref().clone(), - no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), - use_manual_redirects: false, - segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), - hls_proxy: Arc::clone(&app_state.hls_proxy), - segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), - segment_worker_pool: Arc::clone(app_state.hls_proxy.segment_worker_pool()), - map_worker_pool: Arc::clone(app_state.hls_proxy.map_worker_pool()), - origin_manifest_timeout_ms: app_state.hls_proxy.origin_manifest_timeout_ms(), - manifest_recovery_burst: app_state.hls_proxy.manifest_recovery_burst(), - strip: app_state.hls_proxy.strip(), - retry_policy: RetryPolicy { delays_ms: [0; 5], jitter_max_ms: 0 }, - reverse_proxy_rewrite_secret: app_state.get_encrypt_secret().to_vec(), - transient_resource_ttl_ms: app_state.hls_proxy.transient_resource_ttl_ms(), - manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, - fresh_manifest_requirement_generation: None, - acceptance_directive: HlsManifestAcceptanceDirective::none(), - access_lease_id, - disabled_headers: None, - now_ms: super::current_time_millis(), - origin_io: None, - post_refresh_runtime: None, - } - } - - async fn wait_for_ready_timeline(session: &HlsSessionHandle, expected_ready: usize) { - let wait = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let ready = session - .read() - .await - .segments - .values() - .filter(|segment| matches!(segment.status, SegmentCacheStatus::Ready { .. })) - .count(); - if ready >= expected_ready { - break; - } - tokio::task::yield_now().await; - } - }) - .await; - if wait.is_err() { - let session = session.read().await; - let statuses = session - .segments - .values() - .map(|segment| (segment.proxy_seq, segment.status.clone())) - .collect::>(); - panic!("READY timeline deadline: expected={expected_ready} statuses={statuses:?}"); - } - } - - async fn extend_ready_segment_as_sparse_file( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_seq: u64, - logical_size: u64, - ) { - let cache_key = session.read().await.segments.get(&proxy_seq).expect("mapped sparse segment").cache_key.clone(); - let metadata = app_state - .hls_proxy - .segment_cache() - .metadata(&cache_key) - .await - .expect("sparse cache metadata read") - .expect("READY sparse cache object"); - let file = - tokio::fs::OpenOptions::new().write(true).open(&metadata.path).await.expect("sparse cache object opens"); - file.set_len(logical_size).await.expect("sparse cache object extends"); - let mut session = session.write().await; - let segment = session.segments.get_mut(&proxy_seq).expect("sparse segment remains mapped"); - segment.status = - SegmentCacheStatus::Ready { content_length: logical_size, ready_at_ms: super::current_time_millis() }; - session.advance_media_readiness_generation(); - session.render_and_store_manifest(super::current_time_millis()).expect("sparse timeline renders"); - } - - fn terminal_test_asset() -> Arc { - let bytes = bytes::Bytes::from_static(include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../test/fixtures/hls/channel_unavailable.ts" - ))); - let buffer = TransportStreamBuffer::new(bytes.to_vec()); - snapshot_terminal_media_asset(&buffer).expect("terminal test asset is valid") - } - - async fn publish_test_manifest_and_exhaust_configured_acceptance( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - lease_id: &HlsAccessLeaseId, - snapshot: HlsLeaseManifestSnapshot, - now_ms: u64, - ) { - let target_duration_ms = snapshot.target_duration_ms; - let publication_guard = app_state - .hls_proxy - .prepare_access_lease_manifest_publication(lease_id, proxy_session_id, now_ms) - .await - .expect("live lease accepts publication preparation"); - assert!(app_state - .hls_proxy - .commit_access_lease_manifest_publication(lease_id, proxy_session_id, publication_guard, snapshot, now_ms,) - .await - .is_committed()); - - let terminal_response = app_state.app_config.custom_stream_response.load_full(); - let terminal_asset = terminal_response - .as_ref() - .and_then(|responses| responses.channel_unavailable.as_ref()) - .and_then(|buffer| snapshot_terminal_media_asset(buffer).ok()) - .expect("configured terminal test asset"); - let terminal_key = - prepared_terminal_bundle_key(&terminal_asset, target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); - let state = app_state.hls_proxy.start_prepared_terminal_bundle( - terminal_asset, - target_duration_ms, - HLS_TERMINAL_TAIL_SEGMENT_COUNT, - ); - let state = match state { - HlsPreparedTerminalBundleState::Preparing { .. } => app_state - .hls_proxy - .wait_for_prepared_terminal_bundle(terminal_key) - .await - .expect("terminal bundle completion"), - state => state, - }; - assert!(matches!( - state, - HlsPreparedTerminalBundleState::Ready { ref bundle } if bundle.key == terminal_key - )); - - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(proxy_session_id) - .await - .expect("warm shared session"); - let mut session = session.write().await; - session.origin_control.record_media_progress(now_ms, target_duration_ms); - let burst_plan = app_state.hls_proxy.manifest_recovery_burst().level.plan(); - let operation_timeout = HlsOperationTimeoutMs::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms()); - let expected_eta = HlsRecoveryEtaMs::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms()); - let timing = HlsAcceptanceEpisodeTiming::from_input(&HlsAcceptanceEpisodeTimingInput { - started_at_ms: now_ms, - burst_plan, - target_duration_ms, - transition_margin: HlsTransitionMarginMs::from_millis(target_duration_ms), - workload: HlsRecoveryWorkload::clear_fetch(), - observed_latency: HlsObservedRecoveryLatency::default(), - required_terminal_media_key: Some(terminal_key), - terminal_media_preparation: HlsTerminalMediaPreparationState::Ready { key: terminal_key }, - policy: HlsRecoveryTimingPolicy::new(operation_timeout, operation_timeout, expected_eta, expected_eta), - }); - session.origin_control.begin_acceptance_episode( - now_ms, - burst_plan, - HlsManifestAcceptanceTrigger::RecoveryRequired, - &timing, - ); - session.origin_control.path_condition = HlsOriginPathCondition::HardFetchFailure; - let episode = session.origin_control.acceptance_episode.as_mut().expect("acceptance episode"); - episode.record_full_burst(); - episode.record_exhaustion(HlsManifestAcceptanceExhaustionReason::AllFailed); - episode.hold_after_uncommitted_burst(None, None); - } - - async fn terminalize_existing_test_lease( - app_state: &Arc, - proxy_session_id: &str, - lease_id: &str, - base_proxy_seq: u64, - ) -> TransportStreamBuffer { - let proxy_session_id = ProxySessionId(proxy_session_id.to_string()); - let lease_id = HlsAccessLeaseId(lease_id.to_string()); - let buffer = TransportStreamBuffer::new( - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) - .to_vec(), - ); - let asset = snapshot_terminal_media_asset(&buffer).expect("terminal test asset is valid"); - let base_manifest = HlsLeaseManifestSnapshot { - delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, - source_commit_identity: HlsManifestCommitIdentity::new(7), - uri_materialization: None, - finalized_transient_manifest_generation: None, - snapshot_generation: 7, - delivered_at_ms: super::current_time_millis(), - first_proxy_seq: base_proxy_seq, - last_proxy_seq: base_proxy_seq, - visible_segments: Arc::from([HlsLeaseManifestSegment { - proxy_seq: base_proxy_seq, - duration_ms: 4_000, - uri: format!("/iptv/hls/shared/live/{}/{}/{base_proxy_seq:06}.ts", proxy_session_id.0, lease_id.0) - .into(), - discontinuity_before: false, - map_ref_ready: true, - encryption: None, - }]), - discontinuity_sequence: 3, - target_duration_ms: asset.duration_ms().saturating_add(1_000), - playlist_duration_ms: 4_000, - last_visible_media_end_ms: 4_000, - active_map: None, - active_encryption: None, - container: HlsMediaContainer::MpegTs, - }; - let base_timing = Some(HlsTerminalTailBuildInput::base_timing_for_test(&asset, &base_manifest)); - let base_splice_evidence = Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test(&asset)); - let terminal_splice_evidence = base_splice_evidence.clone(); - let plan = build_terminal_tail_plan(HlsTerminalTailBuildInput { - generation: HlsTerminalTailGeneration(17), - created_at_ms: super::current_time_millis(), - base_availability: Arc::from([HlsTerminalBaseSegmentAvailability { - proxy_seq: base_proxy_seq, - media_state: HlsTerminalBaseMediaState::Ready, - required_map_ready: true, - required_key_ready: true, - protection: HlsTerminalBaseProtection::Protectable, - }]), - base_track_signature: Some(asset.track_signature().clone()), - base_splice_evidence, - terminal_splice_evidence, - base_timing, - base_key_bindings: Arc::from([]), - expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable( - HlsTerminalAssetIdentity::from_asset(&asset), - ), - base_manifest: base_manifest.clone(), - anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test( - &asset, - base_manifest.target_duration_ms, - ), - asset, - }) - .expect("terminal test plan is compatible"); - let protection = HlsTerminalTailProtection { - generation: plan.generation, - base_proxy_seqs: Arc::clone(&plan.protected_base_proxy_seqs), - key_bindings: plan.key_bindings(), - }; - { - let mut leases = app_state.hls_proxy.access_leases().write().await; - let mut lease = leases.remove_access_lease(&lease_id).expect("test lease exists before terminal cutover"); - lease.last_manifest_snapshot = Some(base_manifest); - lease.playback_mode = HlsLeasePlaybackMode::TerminalTail(Arc::new(plan)); - leases.prepare_access_lease(lease); - } - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("test session exists before terminal cutover"); - session.write().await.install_terminal_tail_protection(lease_id, protection); - buffer - } - - async fn terminal_test_plan_shape(app_state: &Arc, proxy_session_id: &str, lease_id: &str) -> (u64, u16) { - let snapshot = app_state - .hls_proxy - .access_lease_response_snapshot( - &HlsAccessLeaseId(lease_id.to_string()), - &ProxySessionId(proxy_session_id.to_string()), - super::current_time_millis(), - ) - .await - .expect("terminal test lease snapshot exists"); - let HlsLeasePlaybackMode::TerminalTail(plan) = snapshot.playback_mode else { - panic!("terminal test lease keeps terminal playback mode"); - }; - (plan.generation.0, plan.segment_count) - } - - async fn wait_for_provider_connection_count(app_state: &Arc, expected: usize) { - for _ in 0..50 { - let actual = app_state.active_provider.get_provider_connections_count().await; - if actual == expected { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - assert_eq!(app_state.active_provider.get_provider_connections_count().await, expected); - } - - fn normal_manifest(body: &str) -> crate::processing::parser::hls::origin_manifest::ParsedOriginManifest { - match parse_origin_media_manifest(body, "http://origin.example.com/live/final/index.m3u8") { - OriginManifestParseOutcome::Normal(manifest) => manifest, - OriginManifestParseOutcome::TransientPassthrough { reason } => { - panic!("expected normal manifest: {reason:?}") - } - } - } - - async fn map_segment(app_state: &Arc, proxy_seq: u64, extension: &str) -> String { - map_segment_with_origin_url(app_state, proxy_seq, extension, &format!("{proxy_seq}.{extension}")).await - } - - async fn map_segment_with_origin_url( - app_state: &Arc, - proxy_seq: u64, - _extension: &str, - origin_url: &str, - ) -> String { - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let manifest = - normal_manifest(&format!("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:{proxy_seq}\n#EXTINF:4.0,\n{origin_url}\n")); - let mut session = session.write().await; - session.proxy_next_seq = Some(proxy_seq); - session.apply_origin_manifest(&manifest).expect("manifest should map"); - session.proxy_session_id.0.clone() - } - - async fn map_ready_segment(app_state: &Arc, proxy_seq: u64, extension: &str, body: &[u8]) -> String { - let proxy_session_id = map_ready_segment_without_lease(app_state, proxy_seq, extension, body).await; - grant_hls_proxy_lease(app_state, &proxy_session_id).await; - proxy_session_id - } - - async fn map_ready_segment_without_lease( - app_state: &Arc, - proxy_seq: u64, - extension: &str, - body: &[u8], - ) -> String { - let proxy_session_id = map_segment(app_state, proxy_seq, extension).await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - let cache_key = { - let session = session.read().await; - session.segments.get(&proxy_seq).expect("segment should be mapped").cache_key.clone() - }; - let metadata = app_state - .hls_proxy - .segment_cache() - .write_bytes_and_commit(&cache_key, body) - .await - .expect("cache commit should succeed"); - { - let mut session = session.write().await; - session.segments.get_mut(&proxy_seq).expect("segment should be mapped").status = - SegmentCacheStatus::Ready { content_length: metadata.size, ready_at_ms: 200 }; - } - proxy_session_id - } - - async fn map_hls_map(app_state: &Arc, body: &[u8], grant_lease: bool) -> String { - let session = app_state - .hls_proxy - .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) - .await; - let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n000123.m4s\n"); - let proxy_session_id = { - let mut session = session.write().await; - session.apply_origin_manifest(&manifest).expect("manifest should map"); - session.proxy_session_id.0.clone() - }; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - let cache_key = { - let session = session.read().await; - session.maps.get(&ProxyMapId(0)).expect("map should be mapped").cache_key.clone() - }; - let metadata = app_state - .hls_proxy - .segment_cache() - .write_bytes_and_commit(&cache_key, body) - .await - .expect("map cache commit should succeed"); - { - let mut session = session.write().await; - session.maps.get_mut(&ProxyMapId(0)).expect("map should be mapped").status = - MapCacheStatus::Ready { content_length: metadata.size, ready_at_ms: 200 }; - } - if grant_lease { - grant_hls_proxy_lease(app_state, &proxy_session_id).await; - } - proxy_session_id - } - - async fn map_transient_resource( - app_state: &Arc, - origin_url: &str, - extension: &str, - grant_lease: bool, - ) -> (String, String) { - map_transient_resource_with_kind(app_state, origin_url, extension, grant_lease, TransientResourceKind::Segment) - .await - } - - async fn map_transient_resource_with_kind( - app_state: &Arc, - origin_url: &str, - extension: &str, - grant_lease: bool, - kind: TransientResourceKind, - ) -> (String, String) { - let secret = b"rewrite-secret"; - let now_ms = super::current_time_millis(); - let session = app_state.hls_proxy.get_or_create_session(HlsSessionKey::new(1, "12345"), secret, now_ms).await; - let resource_id = build_transient_resource_id(origin_url, secret); - let proxy_session_id = { - let mut session = session.write().await; - session.mode = - HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; - session.transient.upsert_resources([TransientResourceRef::new( - kind, - origin_url, - secret, - now_ms, - 300_000, - Some(extension.to_string()), - )]); - session.proxy_session_id.0.clone() - }; - if grant_lease { - grant_hls_proxy_lease(app_state, &proxy_session_id).await; - } - (proxy_session_id, resource_id.0) - } - - async fn get_response(app_state: Arc, uri: &str, range: Option<&str>) -> Response { - request_response(app_state, Method::GET, uri, range).await - } - - async fn request_response( - app_state: Arc, - method: Method, - uri: &str, - range: Option<&str>, - ) -> Response { - let router = hls_api_register().with_state(app_state); - let mut request = Request::builder().method(method).uri(uri); - if let Some(range) = range { - request = request.header(header::RANGE, range); - } - let mut request = request.body(Body::empty()).expect("request should build"); - request.extensions_mut().insert(ConnectInfo(test_addr())); - router.oneshot(request).await.expect("response") - } - - async fn get_status(app_state: Arc, uri: &str) -> StatusCode { - get_response(app_state, uri, None).await.status() - } - - async fn hls_session_last_media_at_ms(app_state: &Arc, proxy_session_id: &str) -> Option { - app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.to_string())) - .await - .expect("session should exist") - .read() - .await - .activity - .last_authorized_media_at_ms - } - - async fn assert_no_hls_cache_stream_registered(app_state: &Arc) { - assert!(app_state.active_users.active_streams().await.is_empty()); - } - - async fn response_body(response: Response) -> bytes::Bytes { - response.into_body().collect().await.expect("body should collect").to_bytes() - } - - async fn single_variant_master_playlist(response: Response) -> (u32, String) { - let body = response_body(response).await; - let body = std::str::from_utf8(&body).expect("master playlist should be UTF-8"); - let mut lines = body.lines(); - assert_eq!(lines.next(), Some("#EXTM3U")); - let bandwidth = lines - .next() - .and_then(|line| line.strip_prefix("#EXT-X-STREAM-INF:BANDWIDTH=")) - .and_then(|value| value.parse::().ok()) - .expect("positive master playlist bandwidth"); - let uri = lines.next().expect("single variant URI").to_string(); - assert!(lines.next().is_none(), "master playlist must contain exactly one variant"); - (bandwidth, uri) - } - - async fn single_variant_uri(response: Response) -> String { single_variant_master_playlist(response).await.1 } - - fn access_lease_id_from_variant_uri(uri: &str) -> &str { - uri.trim_end_matches("/manifest.m3u8").rsplit('/').next().expect("access lease id in variant URI") - } - - fn proxy_session_id_from_variant_uri(uri: &str) -> &str { - let mut parts = uri.trim_end_matches("/manifest.m3u8").rsplit('/'); - let _access_lease_id = parts.next().expect("access lease id in variant URI"); - parts.next().expect("proxy session id in variant URI") - } - - fn manifest_media_sequence(body: &str) -> u64 { - body.lines() - .find_map(|line| line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:")) - .and_then(|value| value.parse().ok()) - .expect("media sequence") - } - - async fn access_lease_session_token( - app_state: &Arc, - proxy_session_id: &ProxySessionId, - access_lease_id: &HlsAccessLeaseId, - ) -> String { - app_state - .hls_proxy - .access_lease(access_lease_id, proxy_session_id, super::current_time_millis()) - .await - .expect("access lease should exist") - .user_session_token - } - - #[test] - fn transient_full_object_cacheable_request_accepts_open_zero_range() { - use crate::api::model::is_hls_transient_full_object_cacheable_request; - - assert!(is_hls_transient_full_object_cacheable_request(None)); - assert!(is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=0-")))); - assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=4-")))); - assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=-4")))); - assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=0-1,4-5")))); - } - - async fn assert_hls_cache_stream_registered(app_state: &Arc, proxy_session_id: &str) { - let streams = app_state.active_users.active_streams().await; - assert_eq!(streams.len(), 1); - let stream = &streams[0]; - assert_eq!(stream.username, "hls-user"); - assert_eq!(stream.session_token.as_deref(), Some("hls-session-token")); - assert_eq!(stream.provider.as_ref(), "origin-provider"); - assert_eq!(stream.channel.item_type, PlaylistItemType::LiveHls); - assert!(stream.channel.shared); - assert_eq!( - stream.channel.shared_stream_id, - Some(super::hls_cache_shared_stream_id(&ProxySessionId(proxy_session_id.to_string()))) - ); - assert_eq!(stream.channel.shared_joined_existing, Some(false)); - assert_eq!(stream.channel.url.as_ref(), format!("/hls/shared/live/{proxy_session_id}/manifest.m3u8")); - assert!(!stream.channel.url.contains("test-access-lease")); - assert!(!stream.channel.url.contains("hls-session-token")); - assert!(!stream.channel.url.contains("origin.example.com")); - assert!(!stream.channel.url.contains("/hls/hls-user/")); - } - - fn stats_provider_test_user_session(provider: &str) -> UserSession { - UserSession { - token: "stats-session-token".to_string(), - transition_version: 0, - virtual_id: 12345, - provider: Arc::from(provider), - stream_url: Arc::from("http://origin.example.com/live/12345.m3u8"), - provider_session_headers: HashMap::new(), - addr: test_addr(), - socket_bound: false, - active_addrs: Vec::new(), - ts: 100, - started_at: 100, - permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - lifecycle: PlaybackLifecycle::Active, - } - } - - #[test] - fn hls_cache_stats_provider_prefers_active_origin_account_binding() { - let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); - let proxy_session_id = ProxySessionId("stats-session".to_string()); - let binding = HlsOriginAccountBinding::new( - Arc::clone(&origin_source.input_name), - Arc::from("cdn-dev-alias"), - &proxy_session_id, - 100, - ); - let user_session = stats_provider_test_user_session("cdn-dev"); - - let provider = super::hls_cache_stats_provider(&origin_source, Some(&binding), &user_session); - - assert_eq!(provider.as_ref(), "cdn-dev-alias"); - } - - #[test] - fn hls_cache_stats_provider_falls_back_when_origin_account_binding_is_not_active() { - let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); - let proxy_session_id = ProxySessionId("stats-session".to_string()); - let mut binding = HlsOriginAccountBinding::new( - Arc::clone(&origin_source.input_name), - Arc::from("cdn-dev-alias"), - &proxy_session_id, - 100, - ); - binding.detach(HlsOriginAccountDetachedReason::Cleanup, 200); - let user_session = stats_provider_test_user_session("session-provider"); - - let provider = super::hls_cache_stats_provider(&origin_source, Some(&binding), &user_session); - - assert_eq!(provider.as_ref(), "session-provider"); - } - - #[test] - fn hls_cache_stats_provider_falls_back_to_input_name_without_session_provider() { - let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); - let user_session = stats_provider_test_user_session(""); - - let provider = super::hls_cache_stats_provider(&origin_source, None, &user_session); - - assert_eq!(provider.as_ref(), "cdn-dev"); - } - - async fn register_hls_cache_stream_for_stats_test( - app_state: &Arc, - session: &HlsSessionHandle, - proxy_session_id: &ProxySessionId, - session_token: &str, - fingerprint: &Fingerprint, - lease_id: &str, - ) { - create_active_hls_user_session_with( - app_state, - session_token, - "origin-provider", - "http://origin.example.com/live/12345.m3u8", - fingerprint.addr, - ) - .await; - let context = test_hls_access_context_with( - proxy_session_id.clone(), - HlsAccessLeaseId(lease_id.to_string()), - session_token, - fingerprint.key.clone(), - ); - super::ensure_hls_cache_stream_registered(app_state, fingerprint, &HeaderMap::new(), &context, session) - .await - .expect("HLS stream registers"); - } - - fn find_stream_by_session_token( - streams: &[shared::model::StreamInfo], - session_token: &str, - ) -> shared::model::StreamInfo { - streams - .iter() - .find(|stream| stream.session_token.as_deref() == Some(session_token)) - .unwrap_or_else(|| panic!("{session_token} stream should exist")) - .clone() - } - - #[tokio::test] - async fn hls_cache_stream_stats_mark_additional_viewers_as_joined_existing() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; - let proxy_session_id = ProxySessionId(proxy_session_id); - let shared_stream_id = super::hls_cache_shared_stream_id(&proxy_session_id); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id) - .await - .expect("session should exist"); - let first_fingerprint = test_fingerprint(); - let second_fingerprint = test_fingerprint_with_addr(test_addr_with_port(55124)); - register_hls_cache_stream_for_stats_test( - &app_state, - &session, - &proxy_session_id, - "hls-session-token", - &first_fingerprint, - "first-access-lease", - ) - .await; - - let streams = app_state.active_users.active_streams().await; - let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); - let first_meter_uid = first_stream.meter_uid; - assert!(first_stream.channel.shared); - assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); - - register_hls_cache_stream_for_stats_test( - &app_state, - &session, - &proxy_session_id, - "hls-second-session-token", - &second_fingerprint, - "second-access-lease", - ) - .await; - - let streams = app_state.active_users.active_streams().await; - let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); - let second_stream = find_stream_by_session_token(&streams, "hls-second-session-token"); - assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); - assert_eq!(second_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(second_stream.channel.shared_joined_existing, Some(true)); - - register_hls_cache_stream_for_stats_test( - &app_state, - &session, - &proxy_session_id, - "hls-session-token", - &first_fingerprint, - "first-access-lease", - ) - .await; - - let streams = app_state.active_users.active_streams().await; - let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); - assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); - assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); - assert_eq!(first_stream.meter_uid, first_meter_uid); - } - - struct TestSegmentOrigin { - base_url: String, - key_requests: Arc, - manifest_requests: Arc, - segment_requests: Arc, - key_bytes: Option>>>, - task: tokio::task::JoinHandle<()>, - } - - impl Drop for TestSegmentOrigin { - fn drop(&mut self) { self.task.abort(); } - } - - impl TestSegmentOrigin { - fn key_request_count(&self) -> usize { self.key_requests.load(Ordering::SeqCst) } - - fn manifest_request_count(&self) -> usize { self.manifest_requests.load(Ordering::SeqCst) } - - fn segment_request_count(&self) -> usize { self.segment_requests.load(Ordering::SeqCst) } - - async fn set_key_bytes(&self, bytes: Arc<[u8]>) { - if let Some(key_bytes) = &self.key_bytes { - *key_bytes.write().await = bytes; - } - } - } - - async fn spawn_test_segment_origin(body: &'static [u8]) -> TestSegmentOrigin { - spawn_test_status_origin(StatusCode::OK, body).await - } - - async fn spawn_test_encrypted_hls_origin( - manifest: &'static [u8], - key_bytes: Arc<[u8]>, - plaintext_segment: Arc<[u8]>, - ) -> TestSegmentOrigin { - let manifest = Arc::<[u8]>::from(manifest); - let key_bytes = Arc::new(RwLock::new(key_bytes)); - let key_bytes_for_task = Arc::clone(&key_bytes); - let key_requests = Arc::new(AtomicUsize::new(0)); - let key_requests_for_task = Arc::clone(&key_requests); - let manifest_requests = Arc::new(AtomicUsize::new(0)); - let manifest_requests_for_task = Arc::clone(&manifest_requests); - let segment_requests = Arc::new(AtomicUsize::new(0)); - let segment_requests_for_task = Arc::clone(&segment_requests); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); - let addr = listener.local_addr().expect("local addr"); - let task = tokio::spawn(async move { - loop { - let Ok((mut socket, _)) = listener.accept().await else { - break; - }; - let manifest = Arc::clone(&manifest); - let key_bytes = Arc::clone(&key_bytes_for_task); - let key_requests = Arc::clone(&key_requests_for_task); - let manifest_requests = Arc::clone(&manifest_requests_for_task); - let segment_requests = Arc::clone(&segment_requests_for_task); - let plaintext_segment = Arc::clone(&plaintext_segment); - tokio::spawn(async move { - let mut request = vec![0_u8; 2048]; - let Ok(read) = socket.read(&mut request).await else { - return; - }; - if read == 0 { - return; - } - let path = String::from_utf8_lossy(&request[..read]) - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .map_or_else(|| "/".to_string(), str::to_owned); - let current_key_bytes = Arc::clone(&*key_bytes.read().await); - let body = if path_has_extension(&path, "m3u8") { - manifest_requests.fetch_add(1, Ordering::SeqCst); - manifest - } else if path.ends_with("key.bin") { - key_requests.fetch_add(1, Ordering::SeqCst); - current_key_bytes - } else if let Some(origin_sequence) = path - .rsplit('/') - .next() - .and_then(|file| file.strip_suffix(".ts")) - .and_then(|value| value.parse::().ok()) - { - segment_requests.fetch_add(1, Ordering::SeqCst); - Arc::from(encrypt_test_aes128_cbc_pkcs7( - &plaintext_segment, - ¤t_key_bytes, - test_hls_sequence_iv(origin_sequence), - )) - } else { - Arc::<[u8]>::from([]) - }; - let response = - format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); - if socket.write_all(response.as_bytes()).await.is_ok() { - let _ = socket.write_all(&body).await; - } - }); - } - }); - TestSegmentOrigin { - base_url: format!("http://{addr}"), - key_requests, - manifest_requests, - segment_requests, - key_bytes: Some(key_bytes), - task, - } - } - - fn encrypt_test_aes128_cbc_pkcs7(plaintext: &[u8], key: &[u8], iv: [u8; 16]) -> Vec { - let padding_len = 16 - (plaintext.len() % 16); - let mut ciphertext = plaintext.to_vec(); - ciphertext.resize( - plaintext.len().saturating_add(padding_len), - u8::try_from(padding_len).expect("PKCS#7 AES-128 padding fits in u8"), - ); - let cipher = Aes128::new_from_slice(key).expect("test key has AES-128 length"); - let mut previous = iv; - for block in ciphertext.as_chunks_mut::<16>().0 { - for (byte, previous) in block.iter_mut().zip(previous) { - *byte ^= previous; - } - let mut encrypted = Block::::default(); - encrypted.copy_from_slice(block); - cipher.encrypt_block(&mut encrypted); - block.copy_from_slice(&encrypted); - previous.copy_from_slice(block); - } - ciphertext - } - - fn test_hls_sequence_iv(sequence: u64) -> [u8; 16] { - let mut iv = [0_u8; 16]; - iv[8..].copy_from_slice(&sequence.to_be_bytes()); - iv - } - - struct TestBinaryOriginResponse { - status: StatusCode, - location: Option, - body: Arc<[u8]>, - } - - impl TestBinaryOriginResponse { - fn new(status: StatusCode, body: Arc<[u8]>) -> Self { Self { status, location: None, body } } - - fn redirect(location: String) -> Self { - Self { status: StatusCode::FOUND, location: Some(location), body: Arc::from(&b""[..]) } - } - } - - type TestBinaryOriginHandler = Arc TestBinaryOriginResponse + Send + Sync>; - - async fn spawn_test_binary_origin(handler: TestBinaryOriginHandler) -> TestSegmentOrigin { - let key_requests = Arc::new(AtomicUsize::new(0)); - let manifest_requests = Arc::new(AtomicUsize::new(0)); - let segment_requests = Arc::new(AtomicUsize::new(0)); - let manifest_requests_for_task = Arc::clone(&manifest_requests); - let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); - let addr = listener.local_addr().expect("local addr"); - let task = tokio::spawn(async move { - loop { - let Ok((mut socket, _)) = listener.accept().await else { - break; - }; - let handler = Arc::clone(&handler); - let manifest_requests = Arc::clone(&manifest_requests_for_task); - tokio::spawn(async move { - let mut buf = vec![0_u8; 2048]; - let Ok(read) = socket.read(&mut buf).await else { - return; - }; - if read == 0 { - return; - } - let request = String::from_utf8_lossy(&buf[..read]); - let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("/"); - if path_has_extension(path, "m3u8") { - manifest_requests.fetch_add(1, Ordering::SeqCst); - } - let TestBinaryOriginResponse { status, location, body } = handler(path); - let reason = status.canonical_reason().unwrap_or("Status"); - let location_header = - location.map_or_else(String::new, |location| format!("Location: {location}\r\n")); - let response = format!( - "HTTP/1.1 {} {reason}\r\n{location_header}Content-Length: {}\r\nConnection: close\r\n\r\n", - status.as_u16(), - body.len() - ); - let _ = socket.write_all(response.as_bytes()).await; - let _ = socket.write_all(&body).await; - }); - } - }); - TestSegmentOrigin { - base_url: format!("http://{addr}"), - key_requests, - manifest_requests, - segment_requests, - key_bytes: None, - task, - } - } - - async fn spawn_test_status_origin(status: StatusCode, body: &'static [u8]) -> TestSegmentOrigin { - let body = Arc::<[u8]>::from(body); - spawn_test_binary_origin(Arc::new(move |_path| TestBinaryOriginResponse::new(status, Arc::clone(&body)))).await - } - - struct TestEncodedManifestOrigin { - base_url: String, - requests: Arc>>, - task: tokio::task::JoinHandle<()>, - } - - impl Drop for TestEncodedManifestOrigin { - fn drop(&mut self) { self.task.abort(); } - } - - async fn spawn_test_encoded_manifest_origin( - content_encoding: Option<&'static str>, - body: Vec, - body_delay: Duration, - ) -> TestEncodedManifestOrigin { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); - let addr = listener.local_addr().expect("local addr"); - let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); - let task_requests = Arc::clone(&requests); - let body = Arc::new(body); - let task = tokio::spawn(async move { - loop { - let Ok((mut socket, _)) = listener.accept().await else { - break; - }; - let requests = Arc::clone(&task_requests); - let body = Arc::clone(&body); - tokio::spawn(async move { - let mut request = Vec::new(); - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let mut chunk = [0_u8; 2048]; - let Ok(read) = socket.read(&mut chunk).await else { - return; - }; - if read == 0 { - return; - } - request.extend_from_slice(&chunk[..read]); - } - requests.lock().await.push(String::from_utf8_lossy(&request).to_string()); - let mut response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n", body.len()); - if let Some(content_encoding) = content_encoding { - let _ = writeln!(&mut response, "Content-Encoding: {content_encoding}\r"); - } - response.push_str("Connection: close\r\n\r\n"); - let _ = socket.write_all(response.as_bytes()).await; - if body_delay.is_zero() { - let _ = socket.write_all(body.as_slice()).await; - } else { - let split_at = body.len().min(4); - let _ = socket.write_all(&body[..split_at]).await; - tokio::time::sleep(body_delay).await; - let _ = socket.write_all(&body[split_at..]).await; - } - }); - } - }); - TestEncodedManifestOrigin { base_url: format!("http://{addr}"), requests, task } - } - - fn legacy_manifest_test_input(origin: &TestEncodedManifestOrigin) -> crate::model::InputSource { - crate::model::InputSource { - name: Arc::from("legacy-content-coding-test"), - url: format!("{}/manifest.m3u8", origin.base_url), - provider: None, - username: None, - password: None, - method: shared::model::InputFetchMethod::GET, - headers: HashMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]), - } - } - - fn legacy_manifest_test_client_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); - headers - } - - async fn encode_test_manifest(content_encoding: &str, body: &[u8]) -> Vec { - match content_encoding { - "gzip" => { - let mut encoder = async_compression::tokio::write::GzipEncoder::new(Vec::new()); - encoder.write_all(body).await.expect("gzip test body encodes"); - encoder.shutdown().await.expect("gzip test encoder finishes"); - encoder.into_inner() - } - "deflate" => { - let mut encoder = async_compression::tokio::write::DeflateEncoder::new(Vec::new()); - encoder.write_all(body).await.expect("deflate test body encodes"); - encoder.shutdown().await.expect("deflate test encoder finishes"); - encoder.into_inner() - } - "br" => { - let mut encoder = async_compression::tokio::write::BrotliEncoder::new(Vec::new()); - encoder.write_all(body).await.expect("brotli test body encodes"); - encoder.shutdown().await.expect("brotli test encoder finishes"); - encoder.into_inner() - } - "zstd" => { - let mut encoder = async_compression::tokio::write::ZstdEncoder::new(Vec::new()); - encoder.write_all(body).await.expect("zstd test body encodes"); - encoder.shutdown().await.expect("zstd test encoder finishes"); - encoder.into_inner() - } - _ => panic!("unsupported test Content-Encoding: {content_encoding}"), - } - } - - async fn wait_for_hls_test_session(app_state: &Arc, session_key: &HlsSessionKey) -> HlsSessionHandle { - for _ in 0..50 { - if let Some(session) = app_state.hls_proxy.sessions().get_by_key(session_key).await { - return session; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("shared hls test session should be created"); - } - - async fn wait_for_hls_refresh_in_flight(session: &HlsSessionHandle) { - for _ in 0..50 { - if session.read().await.origin_refresh.in_flight { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("shared hls refresh should be in flight"); - } - - struct TestTransientOrigin { - base_url: String, - requests: Arc>>, - task: tokio::task::JoinHandle<()>, - } - - impl Drop for TestTransientOrigin { - fn drop(&mut self) { self.task.abort(); } - } - - async fn spawn_test_transient_origin() -> TestTransientOrigin { - spawn_test_transient_origin_with_response( - "206 Partial Content", - &[ - ("Content-Type", "video/mp2t"), - ("Content-Range", "bytes 2-15/16"), - ("Accept-Ranges", "bytes"), - ("Cache-Control", "no-store"), - ("ETag", "\"abc\""), - ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), - ], - "transient-body", - ) - .await - } - - async fn spawn_test_transient_origin_with_response( - status_line: &'static str, - response_headers: &'static [(&'static str, &'static str)], - body: &'static str, - ) -> TestTransientOrigin { - spawn_test_transient_origin_with_delayed_binary_response( - status_line, - response_headers, - body.as_bytes().to_vec(), - Duration::ZERO, - ) - .await - } - - async fn spawn_test_transient_origin_with_delayed_response( - status_line: &'static str, - response_headers: &'static [(&'static str, &'static str)], - body: &'static str, - response_delay: Duration, - ) -> TestTransientOrigin { - spawn_test_transient_origin_with_delayed_binary_response( - status_line, - response_headers, - body.as_bytes().to_vec(), - response_delay, - ) - .await - } - - async fn spawn_test_transient_origin_with_delayed_binary_response( - status_line: &'static str, - response_headers: &'static [(&'static str, &'static str)], - body: Vec, - response_delay: Duration, - ) -> TestTransientOrigin { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); - let addr = listener.local_addr().expect("local addr"); - let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); - let task_requests = Arc::clone(&requests); - let body = Arc::new(body); - let task = tokio::spawn(async move { - loop { - let Ok((mut socket, _)) = listener.accept().await else { - break; - }; - let requests = Arc::clone(&task_requests); - let body = Arc::clone(&body); - tokio::spawn(async move { - let mut buf = vec![0_u8; 4096]; - let Ok(read) = socket.read(&mut buf).await else { - return; - }; - if read == 0 { - return; - } - let request = String::from_utf8_lossy(&buf[..read]).to_string(); - requests.lock().await.push(request); - if !response_delay.is_zero() { - tokio::time::sleep(response_delay).await; - } - let mut response_head = format!("HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n", body.len()); - for (name, value) in response_headers { - let _ = writeln!(&mut response_head, "{name}: {value}\r"); - } - response_head.push_str("Connection: close\r\n\r\n"); - let _ = socket.write_all(response_head.as_bytes()).await; - let _ = socket.write_all(body.as_slice()).await; - }); - } - }); - TestTransientOrigin { base_url: format!("http://{addr}"), requests, task } - } - - #[tokio::test] - async fn valid_hls_proxy_segment_without_session_returns_not_found() { - let status = get_status(test_app_state(), "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/000123.ts").await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn valid_hls_proxy_segment_with_not_ready_session_returns_not_found() { - let app_state = test_app_state(); - let proxy_session_id = map_segment(&app_state, 123, "ts").await; - - let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/000123.ts")).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn valid_hls_proxy_segment_with_not_ready_and_valid_lease_returns_service_unavailable() { - let app_state = test_app_state(); - let proxy_session_id = map_segment(&app_state, 123, "ts").await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - session.write().await.segments.get_mut(&123).expect("segment should exist").origin_fetch_ref = None; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers()[header::RETRY_AFTER], "1"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn not_ready_hls_proxy_segment_with_fetch_ref_demand_fetches_and_returns_ok() { - let origin = spawn_test_segment_origin(b"0123456789").await; - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = - map_segment_with_origin_url(&app_state, 123, "ts", &format!("{}/seg.ts", origin.base_url)).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); - } - - #[tokio::test] - async fn not_ready_hls_proxy_segment_with_range_waits_for_demand_fetch_then_returns_partial() { - let origin = spawn_test_segment_origin(b"0123456789").await; - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = - map_segment_with_origin_url(&app_state, 123, "ts", &format!("{}/seg.ts", origin.base_url)).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(app_state, &uri, Some("bytes=2-5")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); - } - - #[tokio::test] - async fn not_ready_hls_proxy_segment_without_fetch_ref_returns_service_unavailable() { - let app_state = test_app_state(); - let proxy_session_id = map_segment(&app_state, 123, "ts").await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - session.write().await.segments.get_mut(&123).expect("segment should exist").origin_fetch_ref = None; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers()[header::RETRY_AFTER], "1"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn ready_hls_proxy_segment_without_lease_returns_not_found() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; - - let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/000123.ts")).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn invalid_normal_segment_uri_never_redirects_or_serves_terminal_media() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "broken.ts").await; - - let response = get_response(app_state, &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_marked_for_gc_returns_not_found_without_redirect() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - session.write().await.mark_for_gc_removal(); - - let response = get_response(app_state, &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_without_range_returns_ok() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp2t"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); - assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); - assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=300, immutable"); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - { - let session = session.read().await; - let segment = session.segments.get(&123).expect("segment should exist"); - assert_eq!(segment.access.active_readers(), 1); - assert!(matches!(segment.status, SegmentCacheStatus::Ready { content_length: 10, .. })); - } - - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); - - { - let session = session.read().await; - let segment = session.segments.get(&123).expect("segment should exist"); - assert_eq!(segment.access.active_readers(), 0); - assert!(segment.access.last_accessed_at_ms() > 0); - assert!(session.activity.last_authorized_media_at_ms.is_some()); - } - assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; - } - - #[tokio::test] - async fn ready_hls_proxy_segment_range_zero_open_returns_partial_content() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "m4s", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.m4s").await; - - let response = get_response(app_state, &uri, Some("bytes=0-")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 0-9/10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_range_start_open_returns_partial_content_from_offset() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "m4v", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.m4v").await; - - let response = get_response(app_state, &uri, Some("bytes=4-")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 4-9/10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "6"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"456789")); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_range_start_end_returns_partial_content() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(app_state, &uri, Some("bytes=2-5")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_suffix_range_returns_partial_content() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(app_state, &uri, Some("bytes=-3")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 7-9/10"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"789")); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_unsatisfiable_range_returns_416() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=99-")).await; - - assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); - assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn ready_hls_proxy_segment_multi_range_returns_416() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-1,4-5")).await; - - assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn ready_hls_proxy_map_without_lease_returns_not_found() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_hls_map(&app_state, b"map-body", false).await; - - let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/map/000000.mp4")).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn ready_hls_proxy_map_without_range_returns_ok() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); - assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); - assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; - } - - #[tokio::test] - async fn ready_hls_proxy_map_range_returns_partial_content() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; - - let response = get_response(app_state, &uri, Some("bytes=2-5")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); - } - - #[tokio::test] - async fn ready_hls_proxy_map_multi_range_returns_416_with_zero_content_length() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-1,4-5")).await; - - assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); - assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn hls_proxy_map_not_ready_with_valid_lease_returns_service_unavailable() { - let app_state = test_app_state(); - let session = - app_state.hls_proxy.get_or_create_session(HlsSessionKey::new(1, "12345"), b"rewrite-secret", 100).await; - let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n000123.m4s\n"); - let proxy_session_id = { - let mut session = session.write().await; - session.apply_origin_manifest(&manifest).expect("manifest should map"); - session.proxy_session_id.0.clone() - }; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers()[header::RETRY_AFTER], "1"); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn transient_resource_without_lease_returns_not_found() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", false).await; - - let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/r/{resource_id}.ts")).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn transient_resource_with_valid_lease_streams_origin_response_and_headers() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) - .await - .expect("session should exist"); - { - let mut session = session.write().await; - session.origin_request_headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret")); - session.origin_request_headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret")); - session - .origin_request_headers - .insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); - session.origin_request_headers.insert(header::HOST, HeaderValue::from_static("proxy.example.com")); - session - .origin_request_headers - .insert(HeaderName::from_static("x-tuliprox-main-revision"), HeaderValue::from_static("secret")); - session.origin_request_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("de")); - } - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp2t"); - assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-15/16"); - assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(response.headers()[header::ETAG], "\"abc\""); - assert_eq!(response.headers()[header::LAST_MODIFIED], "Wed, 21 Oct 2015 07:28:00 GMT"); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"transient-body")); - assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); - let origin_requests = origin.requests.lock().await; - let origin_request = origin_requests.first().expect("origin request").to_ascii_lowercase(); - assert!(origin_request.contains("range: bytes=2-15")); - assert!(origin_request.contains("accept-language: de")); - assert!(!origin_request.contains("authorization: bearer secret")); - assert!(!origin_request.contains("cookie: sid=secret")); - assert!(!origin_request.contains("proxy-authorization: basic secret")); - assert!(!origin_request.contains("host: proxy.example.com")); - assert!(!origin_request.contains("x-tuliprox-main-revision")); - assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; - } - - #[tokio::test] - async fn transient_resource_without_range_is_cached_after_first_fetch() { - let app_state = test_app_state(); - let origin = - spawn_test_transient_origin_with_response("200 OK", &[("Content-Type", "video/mp2t")], "0123456789").await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let first = get_response(Arc::clone(&app_state), &uri, None).await; - let second = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::OK); - assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); - assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"0123456789")); - assert_eq!(origin.requests.lock().await.len(), 1); - } - - #[tokio::test] - async fn transient_resource_range_from_zero_is_cached_as_full_object() { - let app_state = test_app_state(); - let origin = - spawn_test_transient_origin_with_response("200 OK", &[("Content-Type", "video/mp2t")], "0123456789").await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let first = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-")).await; - let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=4-")).await; - - assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); - assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"456789")); - let requests = origin.requests.lock().await; - assert_eq!(requests.len(), 1); - let request = requests[0].to_ascii_lowercase(); - assert!(request.contains("accept-encoding: identity")); - assert!(!request.contains("\r\nrange:")); - } - - #[tokio::test] - async fn transient_cache_fill_rejects_identity_partial_without_ready_object_or_temp_file() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(Arc::clone(&hls_proxy)); - disable_custom_stream_response(&app_state); - let origin = spawn_test_transient_origin_with_response( - "206 Partial Content", - &[("Content-Type", "video/mp2t"), ("Content-Range", "bytes 0-3/10")], - "part", - ) - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - let proxy_session_id = ProxySessionId(proxy_session_id); - let cache_key = TransientObjectCacheKey::new(proxy_session_id.clone(), TransientResourceId(resource_id), "ts"); - let session = - hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.expect("session should exist"); - let status = - session.read().await.transient.object_cache.get(&cache_key).expect("transient cache entry").status.clone(); - assert!(matches!(status, TransientObjectCacheStatus::FailedPermanent { status: None, .. })); - assert!(hls_proxy.segment_cache().metadata(&cache_key).await.expect("cache metadata reads").is_none()); - assert!(!hls_proxy.segment_cache().has_active_temp_files()); - assert_eq!(std::fs::read_dir(temp_dir.path()).expect("cache root reads").count(), 0); - - let requests = origin.requests.lock().await; - assert_eq!(requests.len(), 1); - let request = requests[0].to_ascii_lowercase(); - assert!(request.contains("accept-encoding: identity")); - assert!(!request.contains("\r\nrange:")); - } - - #[tokio::test] - async fn transient_resource_range_from_zero_waits_for_inflight_object_cache_fetch() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin_with_delayed_response( - "200 OK", - &[("Content-Type", "video/mp2t")], - "0123456789", - Duration::from_millis(150), - ) - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let first_app_state = Arc::clone(&app_state); - let first_uri = uri.clone(); - let first = tokio::spawn(async move { get_response(first_app_state, &first_uri, Some("bytes=0-")).await }); - for _ in 0..50 { - if origin.requests.lock().await.len() == 1 { - break; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - assert_eq!(origin.requests.lock().await.len(), 1); - - let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-")).await; - let first = first.await.expect("first request joins"); - - assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); - assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"0123456789")); - assert_eq!(origin.requests.lock().await.len(), 1); - } - - #[tokio::test] - async fn transient_resource_range_from_offset_without_ready_object_is_not_cached() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let first = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; - let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; - - assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(origin.requests.lock().await.len(), 2); - } - - #[tokio::test] - async fn transient_key_resource_is_not_cached() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin_with_response( - "200 OK", - &[("Content-Type", "application/octet-stream")], - "key-bytes", - ) - .await; - let (proxy_session_id, resource_id) = map_transient_resource_with_kind( - &app_state, - &format!("{}/key.bin", origin.base_url), - "key", - true, - TransientResourceKind::Key, - ) - .await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.key")).await; - - let first = get_response(Arc::clone(&app_state), &uri, None).await; - let second = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(first.status(), StatusCode::OK); - assert_eq!(second.status(), StatusCode::OK); - assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"key-bytes")); - assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"key-bytes")); - assert_eq!(origin.requests.lock().await.len(), 2); - } - - #[tokio::test] - async fn transient_resource_origin_error_does_not_mark_media_activity() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin_with_response( - "500 Internal Server Error", - &[("Content-Type", "text/plain")], - "origin-error", - ) - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(response.headers().get(header::RETRY_AFTER).and_then(|value| value.to_str().ok()), Some("1")); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn transient_resource_permanent_origin_error_never_redirects_to_manifest() { - let app_state = test_app_state(); - let origin = - spawn_test_transient_origin_with_response("404 Not Found", &[("Content-Type", "text/plain")], "missing") - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn transient_resource_permanent_origin_error_returns_not_found_when_custom_response_disabled() { - let app_state = test_app_state(); - disable_custom_stream_response(&app_state); - let origin = - spawn_test_transient_origin_with_response("404 Not Found", &[("Content-Type", "text/plain")], "missing") - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn transient_resource_holds_provider_handle_until_origin_body_is_finished() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let proxy_session_id_value = ProxySessionId(proxy_session_id.clone()); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id_value) - .await - .expect("session should exist"); - { - let mut session = session.write().await; - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from("account-a"), - &proxy_session_id_value, - super::current_time_millis(), - )); - } - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; - - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - wait_for_provider_connection_count(&app_state, 1).await; - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"transient-body")); - wait_for_provider_connection_count(&app_state, 0).await; - } - - #[tokio::test] - async fn transient_decoder_failure_releases_origin_and_access_guards_once() { - let input = overlap_provider_input(); - let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); - let mut truncated = encode_test_manifest("gzip", b"transient decoder failure").await; - truncated.truncate(truncated.len().saturating_sub(8)); - let origin = spawn_test_transient_origin_with_delayed_binary_response( - "200 OK", - &[("Content-Type", "video/mp2t"), ("Content-Encoding", "gzip")], - truncated, - Duration::ZERO, - ) - .await; - let (proxy_session_id, resource_id) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let proxy_session_id_value = ProxySessionId(proxy_session_id.clone()); - let session = app_state - .hls_proxy - .sessions() - .get_by_proxy_session_id(&proxy_session_id_value) - .await - .expect("session should exist"); - { - let mut session = session.write().await; - session.origin_account_binding = Some(HlsOriginAccountBinding::new( - Arc::clone(&input.name), - Arc::from("account-a"), - &proxy_session_id_value, - super::current_time_millis(), - )); - } - let resource = session - .read() - .await - .transient - .resources - .get(&TransientResourceId(resource_id.clone())) - .cloned() - .expect("transient resource exists"); - let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; - - let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-")).await; - - assert_eq!(response.status(), StatusCode::OK); - wait_for_provider_connection_count(&app_state, 1).await; - assert_eq!(resource.active_readers(), 1); - assert!(response.into_body().collect().await.is_err()); - wait_for_provider_connection_count(&app_state, 0).await; - assert_eq!(resource.active_readers(), 0); - tokio::task::yield_now().await; - assert_eq!(app_state.active_provider.get_provider_connections_count().await, 0); - assert_eq!(origin.requests.lock().await.len(), 1, "body failure must not start another origin request"); - } - - #[tokio::test] - async fn transient_unknown_resource_never_redirects_to_manifest() { - let app_state = test_app_state(); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, _) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "r/unknown.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(!response.headers().contains_key(header::LOCATION)); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[tokio::test] - async fn transient_unknown_resource_returns_not_found_when_custom_response_disabled() { - let app_state = test_app_state(); - disable_custom_stream_response(&app_state); - let origin = spawn_test_transient_origin().await; - let (proxy_session_id, _) = - map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "r/unknown.ts").await; - - let response = get_response(Arc::clone(&app_state), &uri, None).await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); - assert_no_hls_cache_stream_registered(&app_state).await; - } - - #[test] - fn transient_cross_origin_redirect_strips_sensitive_headers() { - let mut headers = HeaderMap::new(); - headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret")); - headers.insert(header::COOKIE, HeaderValue::from_static("session=secret")); - headers.insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); - headers.insert(header::HOST, HeaderValue::from_static("origin.example.com")); - headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-1")); - - crate::api::model::scrub_hls_origin_headers(&mut headers, None); - - assert!(!headers.contains_key(header::AUTHORIZATION)); - assert!(!headers.contains_key(header::COOKIE)); - assert!(!headers.contains_key("proxy-authorization")); - assert!(!headers.contains_key(header::HOST)); - assert_eq!(headers[header::RANGE], "bytes=0-1"); - } - - #[tokio::test] - async fn ready_hls_proxy_segment_unknown_range_unit_is_ignored() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); - let app_state = test_app_state_with_hls_proxy(hls_proxy); - let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; - let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; - - let response = get_response(app_state, &uri, Some("items=0-1")).await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); - } - - #[tokio::test] - async fn invalid_hls_proxy_file_names_return_not_found() { - let app_state = test_app_state(); - - assert_eq!( - get_status(Arc::clone(&app_state), "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/123.ts").await, - StatusCode::NOT_FOUND - ); - assert_eq!( - get_status(app_state, "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/map/000123.exe").await, - StatusCode::NOT_FOUND - ); - } - - #[tokio::test] - async fn legacy_hls_route_remains_registered() { - let status = get_status(test_app_state(), "/hls/user/pass/1/2/3/not-a-token").await; - - assert_ne!(status, StatusCode::NOT_FOUND); - } -} diff --git a/backend/app/src/api/endpoints/hls_api/catchup.rs b/backend/app/src/api/endpoints/hls_api/catchup.rs new file mode 100644 index 000000000..483962b9e --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/catchup.rs @@ -0,0 +1,319 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +/// +/// `BitTV` archive media URLs look like `2026/07/24/14/13/38-06800.ts` and lose Flussonic +/// path markers after HLS rewrite, so the panel would otherwise keep showing Live + live EPG. +pub(in crate::api) fn m3u_catchup_epg_reference_from_session_token(session_token: &str) -> Option { + let rest = session_token.strip_prefix("m3u-catchup|")?; + for marker in ["|archive|", "|timeshift_abs|"] { + if let Some(idx) = rest.rfind(marker) { + let after = &rest[idx + marker.len()..]; + let start = after.split('|').next()?.trim(); + if let Ok(ts) = start.parse::() { + return Some(ts); + } + } + } + None +} + +pub(super) fn resolve_m3u_archive_reference(stream_url: &str, session_token: Option<&str>) -> Option { + m3u_archive_epg_reference_ts(stream_url) + .or_else(|| epg_reference_ts_from_date_tree_path(stream_url)) + .or_else(|| session_token.and_then(m3u_catchup_epg_reference_from_session_token)) +} + +pub(super) fn looks_like_archive_media_path(path: &str) -> bool { + let rel = path.trim_start_matches('/'); + if rel.is_empty() { + return false; + } + rel.starts_with("dvr-") || rel.contains("/dvr-") || epg_reference_ts_from_date_tree_path(rel).is_some() +} + +/// `BitTV` / Flussonic date-tree segments: `YYYY/MM/DD/HH/MM/SS-*.ts` or `dvr-YYYY/...`. +pub(in crate::api) fn epg_reference_ts_from_date_tree_path(path: &str) -> Option { + let owned_path; + let mut rel = path.trim_start_matches('/'); + if let Some(idx) = rel.find('?') { + rel = &rel[..idx]; + } + if rel.contains("://") { + let parsed = Url::parse(rel).ok()?; + owned_path = parsed.path().trim_start_matches('/').to_string(); + rel = owned_path.as_str(); + } + if let Some(rest) = rel.strip_prefix("dvr-") { + rel = rest; + } + let mut parts = rel.split('/'); + let year: i32 = parts.next()?.parse().ok()?; + if !(2000..=2100).contains(&year) { + return None; + } + let month: u32 = parts.next()?.parse().ok()?; + let day: u32 = parts.next()?.parse().ok()?; + let hour: u32 = parts.next()?.parse().ok()?; + let minute: u32 = parts.next()?.parse().ok()?; + let sec_token = parts.next()?.split('-').next()?.trim_end_matches(".ts").trim_end_matches(".m3u8"); + let second: u32 = sec_token.parse().ok()?; + let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, second)?; + Some(naive.and_utc().timestamp()) +} + +/// Join a client-leaked relative DVR/media path against the session's origin URL. +/// +/// When an origin `.m3u8` is force-piped without `rewrite_hls`, players resolve +/// `dvr-2026/...ts?token=` against the proxy playlist URL (`/hls/.../{token}.m3u8`). +pub(super) fn resolve_leaked_hls_relative_origin( + session_stream_url: &str, + relative_path: &str, + request_query: Option<&str>, +) -> Option { + let rel = relative_path.trim_start_matches('/'); + if rel.is_empty() || rel.contains("://") || rel.split('/').any(|segment| matches!(segment, "." | "..")) { + return None; + } + // Only recover archive-style relative paths (BitTV/Flussonic DVR or date trees). + if !looks_like_archive_media_path(rel) { + return None; + } + let parsed = url::Url::parse(session_stream_url).ok()?; + let session_path = parsed.path(); + + // If the session URL is already inside a DVR/date tree, strip back to the stream root + // so sibling relative segments do not nest under the previous segment directory. + let joined = if rel.starts_with("dvr-") { + if let Some(idx) = session_path.find("/dvr-") { + let mut joined = parsed.clone(); + joined.set_path(&format!("{}{}", &session_path[..=idx], rel)); + joined.set_query(None); + joined.into() + } else { + parsed.join(rel).ok()?.into() + } + } else if let Some(idx) = session_path.find("/202") { + let mut joined = parsed.clone(); + joined.set_path(&format!("{}/{}", &session_path[..idx], rel)); + joined.set_query(None); + joined.into() + } else { + parsed.join(rel).ok()?.into() + }; + + if let Some(query) = request_query.filter(|q| !q.is_empty()) { + Some(format!("{joined}?{query}")) + } else { + Some(joined) + } +} + +pub(super) fn legacy_hls_route_allowed_with_cache( + cache_enabled: bool, + decoded_session_token: Option<&str>, + existing_session_token: Option<&str>, +) -> bool { + !cache_enabled + || decoded_session_token.is_some_and(|decoded| { + existing_session_token.is_some_and(|existing| decoded == existing && is_m3u_catchup_session_token(existing)) + }) +} + +pub(super) fn query_flag_is_archive(key: &str) -> bool { + key.eq_ignore_ascii_case("utc") || key.eq_ignore_ascii_case("utcstart") +} + +pub(super) fn query_flag_marks_start_context(key: &str) -> bool { + key.eq_ignore_ascii_case("end") + || key.eq_ignore_ascii_case("duration") + || key.eq_ignore_ascii_case("lutc") + || key.eq_ignore_ascii_case("offset") +} + +pub(in crate::api) fn m3u_archive_epg_reference_ts(stream_url: &str) -> Option { + use crate::iptv::m3u::parse_flussonic_archive_file; + + let parsed = Url::parse(stream_url).ok()?; + // Flussonic / TiviMate path forms: archive|index|video|mono-{utc}-{duration}.m3u8 + // and timeshift_abs / timeshift_rel. Without this, HLS sessions stay LiveHls in the panel. + if let Some(file) = parsed.path_segments().and_then(|mut segments| segments.next_back()) { + if let Some(archive) = parse_flussonic_archive_file(file) { + if let Some(ts) = archive.epg_reference_ts() { + return Some(ts); + } + } + } + // BitTV date-tree: /YYYY/MM/DD/HH/MM/SS-*.ts + if let Some(ts) = epg_reference_ts_from_date_tree_path(parsed.path()) { + return Some(ts); + } + let mut start_ts = None; + let mut has_start_context = false; + for (key, value) in parsed.query_pairs() { + if query_flag_is_archive(&key) { + if let Ok(ts) = value.parse::() { + return Some(ts); + } + } else if key.eq_ignore_ascii_case("start") || key.eq_ignore_ascii_case("timestamp") { + start_ts = value.parse::().ok(); + } else if query_flag_marks_start_context(&key) { + has_start_context = true; + } + } + + has_start_context.then_some(start_ts).flatten() +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn admit_recovered_archive_stream( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &Arc, + req_headers: &HeaderMap, + input: &Arc, + mut session: UserSession, + stream_channel: StreamChannel, +) -> Result<(UserSession, StreamChannel, Option), Box> { + if session.permission == UserConnectionPermission::Exhausted { + return Err(Box::new( + hls_admission_failure_manifest_response( + app_state, + fingerprint, + user, + stream_channel, + session.provider.clone(), + req_headers, + ConnectFailureReason::UserConnectionsExhausted, + ) + .await, + )); + } + if app_state.active_provider.is_over_limit(&session.provider).await { + return Err(Box::new( + hls_admission_failure_manifest_response( + app_state, + fingerprint, + user, + stream_channel, + session.provider.clone(), + req_headers, + ConnectFailureReason::ProviderConnectionsExhausted, + ) + .await, + )); + } + let (connection_admission, grace_mode, _) = crate::api::api_utils::resolve_playback_request_admission( + &app_state.admission_ctx(), + user, + fingerprint, + Some(&session), + &session.token, + true, + crate::api::api_utils::EvictionReentryGuard::Session(&session.token), + false, + false, + ) + .await; + let connection_permission = connection_admission.permission; + let connection_kind = connection_admission.kind.or(session.connection_kind); + session.permission = connection_permission; + if let Some(connection_kind) = connection_kind { + session.connection_kind = Some(connection_kind); + } + if connection_permission == UserConnectionPermission::Exhausted + || (connection_permission == UserConnectionPermission::GracePeriod && connection_kind.is_none()) + { + let provider = if session.provider.is_empty() { input.name.clone() } else { session.provider.clone() }; + return Err(Box::new( + hls_admission_failure_manifest_response( + app_state, + fingerprint, + user, + stream_channel, + provider, + req_headers, + ConnectFailureReason::UserConnectionsExhausted, + ) + .await, + )); + } + Ok((session, stream_channel, grace_mode)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn hls_api_stream_leaked_relative( + fingerprint: Fingerprint, + req_headers: HeaderMap, + app_state: Arc, + user: Arc, + target: Arc, + input: Arc, + stream_id: u32, + mut session: UserSession, + session_stream_url: String, + relative_path: String, + request_query: Option<&str>, +) -> axum::response::Response { + if let Err(e) = check_network_access_only(&user, &fingerprint, &app_state.app_config, &app_state.geoip) { + return e.into_player_response(app_state.app_config.get_auth_error_status()); + } + let Some(origin_url) = resolve_leaked_hls_relative_origin(&session_stream_url, &relative_path, request_query) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + let archive_reference = resolve_m3u_archive_reference(&origin_url, Some(session.token.as_str())) + .or_else(|| epg_reference_ts_from_date_tree_path(&relative_path)) + .or_else(|| epg_reference_ts_from_date_tree_path(&origin_url)); + let is_archive_media = looks_like_archive_media_path(&relative_path) || looks_like_archive_media_path(&origin_url); + session.stream_url = origin_url.intern(); + let mut stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + stream_id, + &session.stream_url, + archive_reference, + Some(session.token.as_str()), + ) + .await; + // Leaked DVR/date-tree segments are always archive playback for the panel, even when the + // prior session was live and the date-tree timestamp could not be parsed. + if is_archive_media { + stream_channel.item_type = PlaylistItemType::Catchup; + stream_channel.cluster = XtreamCluster::Video; + if stream_channel.epg_reference_ts.is_none() { + stream_channel.epg_reference_ts = archive_reference; + } + } + let (session, stream_channel, grace_mode) = match admit_recovered_archive_stream( + &app_state, + &fingerprint, + &user, + &req_headers, + &input, + session, + stream_channel, + ) + .await + { + Ok(admission) => admission, + Err(response) => return *response, + }; + force_provider_stream_response( + &fingerprint, + &app_state, + &session, + stream_channel, + crate::api::api_utils::ForceStreamRequestContext { + req_headers: &req_headers, + input: &input, + user: &user, + session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), + content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, + }, + grace_mode, + ) + .await + .into_response() +} diff --git a/backend/app/src/api/endpoints/hls_api/manifest.rs b/backend/app/src/api/endpoints/hls_api/manifest.rs new file mode 100644 index 000000000..3e94c4a48 --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/manifest.rs @@ -0,0 +1,2590 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +#[derive(Debug, Deserialize)] +pub(super) struct HlsProxyTerminalSegmentPathParams { + pub(super) proxy_session_id: String, + pub(super) hls_access_lease_id: String, + pub(super) generation: String, + pub(super) terminal_file: String, +} + +pub(super) fn hls_custom_video_type_for_failure_reason(reason: ConnectFailureReason) -> CustomVideoStreamType { + match reason { + ConnectFailureReason::UserAccountExpired => CustomVideoStreamType::UserAccountExpired, + ConnectFailureReason::UserConnectionsExhausted => CustomVideoStreamType::UserConnectionsExhausted, + ConnectFailureReason::ProviderConnectionsExhausted => CustomVideoStreamType::ProviderConnectionsExhausted, + ConnectFailureReason::Preempted => CustomVideoStreamType::LowPriorityPreempted, + ConnectFailureReason::Provisioning => CustomVideoStreamType::Provisioning, + ConnectFailureReason::SessionExpired => CustomVideoStreamType::HlsSessionOrLeaseExpired, + ConnectFailureReason::ProviderError + | ConnectFailureReason::ProviderClosed + | ConnectFailureReason::ChannelUnavailable => CustomVideoStreamType::ChannelUnavailable, + } +} + +pub(crate) async fn hls_custom_video_manifest_response( + app_state: &Arc, + user: &ProxyUserCredentials, + video_type: CustomVideoStreamType, + fallback_status: StatusCode, +) -> axum::response::Response { + hls_custom_video_manifest_response_with_virtual_id(app_state, user, video_type, fallback_status, None).await +} + +pub(crate) async fn hls_admission_failure_manifest_response( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + stream_channel: StreamChannel, + provider_name: Arc, + req_headers: &HeaderMap, + reason: ConnectFailureReason, +) -> axum::response::Response { + record_connect_failed_attempt(ConnectFailedAttempt { + app_state, + fingerprint, + user, + stream_channel, + provider_name, + req_headers, + reason, + failure_stage: FailureStage::Admission, + }); + hls_custom_video_manifest_response( + app_state, + user, + hls_custom_video_type_for_failure_reason(reason), + StatusCode::FORBIDDEN, + ) + .await +} + +pub(super) fn apply_hls_proxy_public_path_prefix(hls_content: String, server_path: Option<&str>) -> String { + let Some(path_prefix) = normalize_hls_proxy_public_path_prefix(server_path) else { + return hls_content; + }; + + let uri_attr_prefix = format!("URI=\"{path_prefix}/hls/shared/live/"); + let hls_content = hls_content.replace("URI=\"/hls/shared/live/", &uri_attr_prefix); + if hls_content.is_empty() { + return hls_content; + } + let mut prefixed = String::with_capacity(hls_content.len().saturating_add(path_prefix.len().saturating_mul(4))); + + for part in hls_content.split_inclusive('\n') { + let (line, line_ending) = split_hls_line_ending(part); + if line.starts_with("/hls/shared/live/") { + prefixed.push_str(&path_prefix); + } + prefixed.push_str(line); + prefixed.push_str(line_ending); + } + + prefixed +} + +pub(super) fn normalize_hls_proxy_public_path_prefix(server_path: Option<&str>) -> Option { + let path = server_path?.trim().trim_matches('/'); + if path.is_empty() { + return None; + } + Some(format!("/{path}")) +} + +pub(super) fn split_hls_line_ending(part: &str) -> (&str, &str) { + if let Some(line) = part.strip_suffix("\r\n") { + (line, "\r\n") + } else if let Some(line) = part.strip_suffix('\n') { + (line, "\n") + } else { + (part, "") + } +} + +pub(super) fn materialize_hls_access_manifest( + hls_content: &str, + lease_id: &HlsAccessLeaseId, + server_path: Option<&str>, +) -> String { + let hls_content = hls_content.replace(HLS_ACCESS_LEASE_ID_PLACEHOLDER, &lease_id.0); + apply_hls_proxy_public_path_prefix(hls_content, server_path) +} + +pub(super) fn hls_access_manifest_uses_startup_view(lease_state: HlsAccessLeaseState) -> bool { + matches!(lease_state, HlsAccessLeaseState::Pending | HlsAccessLeaseState::Idle) +} + +pub(super) fn materialize_shared_hls_access_manifest( + hls_content: &str, + lease_id: &HlsAccessLeaseId, + lease_state: HlsAccessLeaseState, + strip: &crate::model::StripConfig, + window_policy: HlsManifestWindowPolicy, + mode: &'static str, + server_path: Option<&str>, +) -> HlsMaterializedSharedManifest { + let (response_body, initial_strip_outcome) = if hls_access_manifest_uses_startup_view(lease_state) { + let view = materialize_initial_hls_strip_view(hls_content, strip, window_policy); + (view.body, Some(view.outcome)) + } else { + (Cow::Borrowed(hls_content), None) + }; + HlsMaterializedSharedManifest { + body: materialize_hls_access_manifest(&response_body, lease_id, server_path), + mode, + initial_strip_outcome, + } +} + +pub(super) struct HlsMaterializedSharedManifest { + pub(super) body: String, + pub(super) mode: &'static str, + pub(super) initial_strip_outcome: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum HlsInitialStripLeaseSkipReason { + LeaseActivated, + LeaseNotStartupView, +} + +impl HlsInitialStripLeaseSkipReason { + pub(super) const fn as_log_reason(self) -> &'static str { + match self { + Self::LeaseActivated => "lease-activated", + Self::LeaseNotStartupView => "lease-not-startup-view", + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum HlsInitialStripPublicationDiagnostic { + Applied { mode: &'static str, strip_mode: &'static str, configured: u64, effective: usize, visible_segments: usize }, + Skipped { mode: &'static str, reason: HlsInitialStripSkipReason, visible_segments: usize }, + SkippedForLeaseState { mode: &'static str, reason: HlsInitialStripLeaseSkipReason }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum HlsInitialStripPublicationStatus { + NotCommitted, + Committed, +} + +pub(super) fn hls_initial_strip_publication_diagnostic( + publication_status: HlsInitialStripPublicationStatus, + lease_state: HlsAccessLeaseState, + materialized: &HlsMaterializedSharedManifest, +) -> Option { + match publication_status { + HlsInitialStripPublicationStatus::NotCommitted => return None, + HlsInitialStripPublicationStatus::Committed => {} + } + Some(match &materialized.initial_strip_outcome { + Some(HlsInitialStripOutcome::Applied { mode: strip_mode, configured, effective, visible_segments }) => { + HlsInitialStripPublicationDiagnostic::Applied { + mode: materialized.mode, + strip_mode, + configured: *configured, + effective: *effective, + visible_segments: *visible_segments, + } + } + Some(HlsInitialStripOutcome::Skipped { reason, visible_segments }) => { + HlsInitialStripPublicationDiagnostic::Skipped { + mode: materialized.mode, + reason: *reason, + visible_segments: *visible_segments, + } + } + None => HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { + mode: materialized.mode, + reason: if lease_state == HlsAccessLeaseState::Activated { + HlsInitialStripLeaseSkipReason::LeaseActivated + } else { + HlsInitialStripLeaseSkipReason::LeaseNotStartupView + }, + }, + }) +} + +pub(super) fn hls_entry_master_playlist_response( + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + bandwidth: HlsMasterBandwidth, + server_path: Option<&str>, +) -> HlsEntryMasterPlaylistResponse { + let path_prefix = normalize_hls_proxy_public_path_prefix(server_path).unwrap_or_default(); + let media_playlist_uri = format!("{path_prefix}{}", hls_canonical_manifest_path(proxy_session_id, access_lease_id)); + let body = HlsSingleVariantMasterPlaylist::new(bandwidth, media_playlist_uri).render().into_bytes(); + let content_length = body.len(); + let response = try_unwrap_body!(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(header::CACHE_CONTROL, "private, no-store, no-cache, must-revalidate") + .header(header::CONTENT_LENGTH, content_length) + .body(Body::from(body))); + HlsEntryMasterPlaylistResponse { response, content_length } +} + +pub(super) fn hls_canonical_manifest_path( + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, +) -> String { + format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, access_lease_id.0) +} + +pub(super) fn hls_canonical_retry_after_response() -> axum::response::Response { + try_unwrap_body!(axum::response::Response::builder() + .status(axum::http::StatusCode::SERVICE_UNAVAILABLE) + .header(axum::http::header::RETRY_AFTER, cold_start_retry_after_seconds().to_string()) + .body(axum::body::Body::empty())) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HlsCanonicalOwnerRegistration { + Join(HlsCanonicalOwnerRegistrationKind), + FailClosed(HlsCanonicalOwnerRegistrationFailure), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HlsCanonicalOwnerRegistrationKind { + Scheduled, + AlreadyOwned, +} + +impl HlsCanonicalOwnerRegistrationKind { + pub(super) const fn as_label(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::AlreadyOwned => "already_owned", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HlsCanonicalOwnerRegistrationFailure { + CapacityExceeded, + RuntimeUnavailable, +} + +impl HlsCanonicalOwnerRegistrationFailure { + pub(super) const fn as_label(self) -> &'static str { + match self { + Self::CapacityExceeded => "capacity_exceeded", + Self::RuntimeUnavailable => "runtime_unavailable", + } + } +} + +pub(super) const fn hls_canonical_owner_registration( + registration: HlsAvailabilityReevaluationRegistration, +) -> HlsCanonicalOwnerRegistration { + match registration { + HlsAvailabilityReevaluationRegistration::Scheduled => { + HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::Scheduled) + } + HlsAvailabilityReevaluationRegistration::AlreadyOwned | HlsAvailabilityReevaluationRegistration::Superseded => { + HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) + } + HlsAvailabilityReevaluationRegistration::CapacityExceeded => { + HlsCanonicalOwnerRegistration::FailClosed(HlsCanonicalOwnerRegistrationFailure::CapacityExceeded) + } + HlsAvailabilityReevaluationRegistration::RuntimeUnavailable => { + HlsCanonicalOwnerRegistration::FailClosed(HlsCanonicalOwnerRegistrationFailure::RuntimeUnavailable) + } + } +} + +pub(super) fn hls_availability_reevaluation_registration_failure_response( + failure: HlsCanonicalOwnerRegistrationFailure, +) -> axum::response::Response { + warn!("HLS availability reevaluation not registered: reason={}", failure.as_label()); + hls_canonical_retry_after_response() +} + +pub(super) enum HlsCanonicalOwnerResolution { + Live(axum::response::Response), + Terminal(axum::response::Response), + Standalone(axum::response::Response), + FailedClosed { reason: HlsCanonicalOwnerFailureReason, response: axum::response::Response }, +} + +impl HlsCanonicalOwnerResolution { + pub(super) const fn outcome_label(&self) -> &'static str { + match self { + Self::Live(_) => "live", + Self::Terminal(_) => "terminal", + Self::Standalone(_) => "standalone", + Self::FailedClosed { reason, .. } => reason.as_label(), + } + } + + pub(super) fn into_response(self) -> axum::response::Response { + match self { + Self::Live(response) + | Self::Terminal(response) + | Self::Standalone(response) + | Self::FailedClosed { response, .. } => response, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HlsCanonicalOwnerFailureReason { + Superseded, + DeadlineElapsed, + LeaseUnavailable, +} + +impl HlsCanonicalOwnerFailureReason { + pub(super) const fn as_label(self) -> &'static str { + match self { + Self::Superseded => "superseded", + Self::DeadlineElapsed => "deadline_elapsed", + Self::LeaseUnavailable => "lease_unavailable", + } + } + + pub(super) fn response(self) -> axum::response::Response { + let reason = match self { + Self::DeadlineElapsed => HlsTerminalFailedClosedReason::SafeCommitDeadlineElapsed, + Self::Superseded | Self::LeaseUnavailable => HlsTerminalFailedClosedReason::LeaseStateUnavailable, + }; + hls_terminal_failed_closed_response(reason) + } +} + +pub(super) struct HlsCanonicalOwnerPending { + pub(super) deadline_ms: u64, + pub(super) current_session_available: bool, +} + +pub(super) enum HlsCanonicalOwnerEvaluation { + Resolved(HlsCanonicalOwnerResolution), + Pending(HlsCanonicalOwnerPending), +} + +pub(super) fn hls_canonical_owner_failed(reason: HlsCanonicalOwnerFailureReason) -> HlsCanonicalOwnerEvaluation { + HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::FailedClosed { + reason, + response: reason.response(), + }) +} + +pub(super) struct HlsCanonicalOwnerHandoffContext<'a> { + pub(super) app_state: &'a Arc, + pub(super) proxy_session_id: &'a ProxySessionId, + pub(super) access_lease_id: &'a HlsAccessLeaseId, + pub(super) expected_lease_issued_at_ms: Option, + pub(super) strip: &'a crate::model::StripConfig, + pub(super) server_path: Option<&'a str>, + pub(super) manifest_commit_requirement: HlsManifestCommitRequirement, + pub(super) manifest_boundary_rendered_at_ms: u64, + pub(super) bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, + pub(super) request_deadline_ms: u64, + pub(super) safe_session: String, +} + +pub(super) fn hls_canonical_owner_lease_deadline_ms(lease: &HlsAccessLease) -> u64 { + if lease.state == HlsAccessLeaseState::Pending { + lease.pending_deadline_ms().unwrap_or(lease.valid_until_ms) + } else { + lease.valid_until_ms + } +} + +pub(super) fn hls_canonical_owner_request_deadline_ms( + lease: &HlsAccessLease, + wait_timeout: Duration, + now_ms: u64, +) -> u64 { + let lease_deadline_ms = hls_canonical_owner_lease_deadline_ms(lease); + if wait_timeout.is_zero() && lease.state == HlsAccessLeaseState::Pending { + lease_deadline_ms + } else { + lease_deadline_ms.min(now_ms.saturating_add(duration_to_millis_saturating(wait_timeout))) + } +} + +pub(super) async fn evaluate_hls_canonical_owner_handoff( + context: &HlsCanonicalOwnerHandoffContext<'_>, +) -> HlsCanonicalOwnerEvaluation { + let now_ms = current_time_millis(); + let Some(lease) = context + .app_state + .hls_proxy + .access_lease_response_snapshot(context.access_lease_id, context.proxy_session_id, now_ms) + .await + else { + return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); + }; + if context.expected_lease_issued_at_ms != Some(lease.issued_at_ms) { + return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); + } + match &lease.playback_mode { + HlsLeasePlaybackMode::TerminalTail(_) => { + let Some(response) = + hls_terminal_playback_response(&lease, context.proxy_session_id, context.access_lease_id) + else { + return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); + }; + return HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::Terminal(response)); + } + HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => { + return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); + } + HlsLeasePlaybackMode::Live => {} + } + if matches!( + lease.state, + HlsAccessLeaseState::PolicyRevoking | HlsAccessLeaseState::Expired | HlsAccessLeaseState::Denied + ) { + return hls_canonical_owner_failed(HlsCanonicalOwnerFailureReason::LeaseUnavailable); + } + + let current_session = + context.app_state.hls_proxy.sessions().get_by_proxy_session_id(context.proxy_session_id).await; + if let Some(current_session) = current_session.as_ref() { + let options = hls_cached_manifest_options_for_requirement( + Duration::ZERO, + context.manifest_commit_requirement, + context.manifest_boundary_rendered_at_ms, + ); + if let Some(response) = try_hls_cached_manifest_response( + context.app_state, + current_session, + context.access_lease_id, + lease.state, + context.strip, + context.server_path, + options, + context.bandwidth_learning, + ) + .await + .filter(|response| response.status() == StatusCode::OK) + { + return HlsCanonicalOwnerEvaluation::Resolved(HlsCanonicalOwnerResolution::Live(response)); + } + } + HlsCanonicalOwnerEvaluation::Pending(HlsCanonicalOwnerPending { + deadline_ms: hls_canonical_owner_lease_deadline_ms(&lease).min(context.request_deadline_ms), + current_session_available: current_session.is_some(), + }) +} + +pub(super) async fn finalize_hls_canonical_owner_handoff( + context: &HlsCanonicalOwnerHandoffContext<'_>, + pending: HlsCanonicalOwnerPending, + deadline_elapsed: bool, +) -> HlsCanonicalOwnerResolution { + match evaluate_hls_canonical_owner_handoff(context).await { + HlsCanonicalOwnerEvaluation::Resolved(resolution) => resolution, + HlsCanonicalOwnerEvaluation::Pending(current) => { + let response = hls_unpublished_lease_channel_unavailable_response( + context.app_state, + context.proxy_session_id, + context.access_lease_id, + ) + .await; + if response.status() == StatusCode::OK { + return HlsCanonicalOwnerResolution::Standalone(response); + } + let reason = if deadline_elapsed { + HlsCanonicalOwnerFailureReason::DeadlineElapsed + } else if !current.current_session_available && !pending.current_session_available { + HlsCanonicalOwnerFailureReason::Superseded + } else { + HlsCanonicalOwnerFailureReason::LeaseUnavailable + }; + HlsCanonicalOwnerResolution::FailedClosed { reason, response: reason.response() } + } + } +} + +pub(super) async fn join_hls_canonical_manifest_owner( + context: HlsCanonicalOwnerHandoffContext<'_>, + registration: HlsCanonicalOwnerRegistrationKind, +) -> axum::response::Response { + let started_at = tokio::time::Instant::now(); + let coordinator = context.app_state.hls_proxy.availability_reevaluations(); + let resolution = loop { + let mut observer = coordinator.observe_owner(context.proxy_session_id); + let pending = match evaluate_hls_canonical_owner_handoff(&context).await { + HlsCanonicalOwnerEvaluation::Resolved(resolution) => break resolution, + HlsCanonicalOwnerEvaluation::Pending(pending) => pending, + }; + let now_ms = current_time_millis(); + if now_ms >= pending.deadline_ms { + break finalize_hls_canonical_owner_handoff(&context, pending, true).await; + } + let Some(observer) = observer.as_mut() else { + break finalize_hls_canonical_owner_handoff(&context, pending, false).await; + }; + let remaining_ms = pending.deadline_ms.saturating_sub(now_ms); + match tokio::time::timeout(Duration::from_millis(remaining_ms), observer.changed()).await { + Ok( + HlsAvailabilityReevaluationObservation::EvidenceChanged + | HlsAvailabilityReevaluationObservation::OwnerFinished, + ) => {} + Err(_) => break finalize_hls_canonical_owner_handoff(&context, pending, true).await, + } + }; + debug!( + "HLS canonical manifest owner handoff completed: session={} proxy_session={} lease={} registration={} outcome={} wait_ms={}", + context.safe_session, + safe_proxy_session_id(context.proxy_session_id), + safe_hls_access_lease_id(context.access_lease_id), + registration.as_label(), + resolution.outcome_label(), + duration_to_millis_saturating(started_at.elapsed()) + ); + resolution.into_response() +} + +pub(super) async fn hls_direct_refresh_follow_up( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + refresh_request: OriginRefreshRequest, + outcome: HlsOriginRefreshTriggerOutcome, +) -> Option { + match outcome { + HlsOriginRefreshTriggerOutcome::Started + | HlsOriginRefreshTriggerOutcome::SessionUnavailable + | HlsOriginRefreshTriggerOutcome::InFlight + | HlsOriginRefreshTriggerOutcome::DebouncedUntil { .. } => return None, + HlsOriginRefreshTriggerOutcome::RecoveryPressureSuperseded => { + warn!("HLS direct origin refresh evidence superseded; scheduling current availability reevaluation"); + } + HlsOriginRefreshTriggerOutcome::RecoveryPressureStateContention => { + warn!("HLS direct origin refresh state contended; scheduling current availability reevaluation"); + } + } + let Some(owner_key) = app_state.hls_proxy.availability_reevaluation_owner_key(session, proxy_session_id).await + else { + warn!("HLS direct origin refresh follow-up unavailable: reason=session_superseded"); + return Some(hls_canonical_retry_after_response()); + }; + match register_hls_availability_reevaluation(app_state.hls_ctx(), Arc::clone(session), owner_key, refresh_request) { + HlsAvailabilityReevaluationRegistration::Scheduled + | HlsAvailabilityReevaluationRegistration::AlreadyOwned + | HlsAvailabilityReevaluationRegistration::Superseded => None, + HlsAvailabilityReevaluationRegistration::CapacityExceeded => { + warn!("HLS direct origin refresh follow-up unavailable: reason=capacity_exceeded"); + Some(hls_canonical_retry_after_response()) + } + HlsAvailabilityReevaluationRegistration::RuntimeUnavailable => { + warn!("HLS direct origin refresh follow-up unavailable: reason=runtime_unavailable"); + Some(hls_canonical_retry_after_response()) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HlsManifestRefreshOrdering { + Background, + AwaitBeforeTerminalEvaluation, +} + +pub(super) async fn trigger_hls_canonical_manifest_refresh( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + refresh_request: OriginRefreshRequest, + ordering: HlsManifestRefreshOrdering, +) -> Option { + match ordering { + HlsManifestRefreshOrdering::Background => { + let outcome = maybe_trigger_origin_refresh_with_outcome(refresh_request.clone()).await; + hls_direct_refresh_follow_up(app_state, session, proxy_session_id, refresh_request, outcome).await + } + HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation => { + // An already-owned refresh cannot be joined through this call, but + // it must not bypass the lease-specific terminal decision. The + // in-flight owner will still publish its eventual progress/failure. + let _refresh_started = trigger_origin_refresh_sync(refresh_request).await; + let now_ms = current_time_millis(); + let Some(lease) = + app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await + else { + return Some(hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::LeaseStateUnavailable)); + }; + resolve_hls_terminal_manifest_state(app_state, session, proxy_session_id, access_lease_id, lease, now_ms) + .await + .err() + .map(|response| *response) + } + } +} + +pub(super) fn hls_canonical_status_response(status: StatusCode) -> axum::response::Response { + if status == StatusCode::SERVICE_UNAVAILABLE { + hls_canonical_retry_after_response() + } else { + status.into_response() + } +} + +pub(super) struct HlsEntryOriginAccountReservation { + pub(super) request_url: String, + pub(super) session_token: String, + pub(super) provider_handle: Option, + pub(super) selected_provider_config: Option>, +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn try_reserve_hls_entry_origin_account_for_redirect( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + input: &ConfigInput, + virtual_id: u32, + request_url: &str, + user_session_token: &str, + session_owner: &str, + reservation_ttl_secs: u64, + connection_permission: UserConnectionPermission, + connection_kind: crate::api::model::ConnectionKind, + create_user_session: bool, +) -> Option { + let provider_handle = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &fingerprint.addr, + false, + connection_priority_for_kind(user, connection_kind), + connection_kind, + Some(session_owner), + ) + .await?; + + let Some(provider_config) = provider_handle.allocation.get_provider_config() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return None; + }; + let Some(stream_url) = get_stream_alternative_url(request_url, input, &provider_config) else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return None; + }; + + let session_token = if create_user_session { + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token: user_session_token, + virtual_id, + provider: &provider_config.name, + stream_url: &stream_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(connection_kind), + socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), + }) + .await + } else { + user_session_token.to_string() + }; + + app_state + .active_provider + .refresh_provider_reservation(&provider_config.name, session_owner, reservation_ttl_secs) + .await; + + Some(HlsEntryOriginAccountReservation { + request_url: stream_url, + session_token, + provider_handle: Some(provider_handle), + selected_provider_config: Some(provider_config), + }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn try_reserve_hls_virtual_entry_origin_account_for_redirect( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + target: &Arc, + input: &ConfigInput, + stream_identity: &HlsEntryStreamIdentity, +) -> bool { + let virtual_id = stream_identity.virtual_id(); + let session_token = + create_playback_session_fingerprint(fingerprint, &user.username, virtual_id, PlaylistItemType::LiveHls, None); + let (connection_admission, _, _) = resolve_playback_request_admission( + &app_state.admission_ctx(), + user, + fingerprint, + None, + &session_token, + false, + EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, + false, + false, + ) + .await; + if connection_admission.permission == UserConnectionPermission::Exhausted { + return false; + } + + let Some(channel) = get_stream_channel(app_state, target, virtual_id).await else { + return false; + }; + let Ok(origin_playlist_url) = + resolve_hls_origin_playlist_url(app_state, target, input, virtual_id, channel.url.as_ref()).await + else { + return false; + }; + let Some(hls_cache_origin) = build_hls_origin_resolution(input, &origin_playlist_url) else { + return false; + }; + let Some(connection_kind) = connection_admission.kind else { + return false; + }; + let (shared_hls_session_owner, reservation_ttl_secs) = if hls_cache_enabled_for_target(app_state, target) { + let origin_source = build_hls_origin_source(input, stream_identity.stream_ref()); + let proxy_session_id = build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); + let reservation_ttl_secs = match app_state.hls_proxy.sessions().get_by_key(&origin_source.session_key()).await { + Some(session) => hls_origin_account_reservation_ttl_secs_for_session(&session).await, + None => hls_origin_account_reservation_ttl_secs_fallback(), + }; + (Some(build_hls_origin_session_owner(&proxy_session_id)), reservation_ttl_secs) + } else { + (None, get_hls_session_ttl_secs(app_state)) + }; + let session_owner = shared_hls_session_owner.as_deref().unwrap_or(session_token.as_str()); + + let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( + app_state, + fingerprint, + user, + input, + virtual_id, + hls_cache_origin.session_entry_url.as_str(), + &session_token, + session_owner, + reservation_ttl_secs, + connection_admission.permission, + connection_kind, + false, + ) + .await + else { + return false; + }; + + app_state.connection_manager.release_provider_handle(reservation.provider_handle).await; + true +} + +pub(super) async fn mark_hls_provisioning_handoff_discontinuity( + app_state: &Arc, + input: &ConfigInput, + stream_identity: &HlsEntryStreamIdentity, + access_lease_id: Option<&HlsAccessLeaseId>, + now_ms: u64, +) -> bool { + if !hls_cache_configured(app_state) { + return false; + } + let origin_source = build_hls_origin_source(input, stream_identity.stream_ref()); + let Some(session) = app_state.hls_proxy.sessions().get_by_key(&origin_source.session_key()).await else { + return false; + }; + mark_hls_provisioning_handoff_discontinuity_once_for_session( + app_state, + &session, + input, + stream_identity.virtual_id(), + access_lease_id, + now_ms, + ) + .await +} + +pub(super) async fn mark_hls_provisioning_handoff_discontinuity_once_for_session( + app_state: &Arc, + session: &HlsSessionHandle, + input: &ConfigInput, + virtual_id: u32, + access_lease_id: Option<&HlsAccessLeaseId>, + now_ms: u64, +) -> bool { + let proxy_session_id = session.read().await.proxy_session_id.clone(); + if !app_state.hls_provisioning.mark_handoff_once( + &input.name, + virtual_id, + Some(&proxy_session_id), + access_lease_id, + now_ms, + ) { + debug!( + "HLS provisioning handoff discontinuity already marked: proxy_session={}", + safe_proxy_session_id(&proxy_session_id) + ); + return false; + } + mark_hls_provisioning_handoff_discontinuity_for_session(session, now_ms).await; + ensure_shared_hls_provisioning_handoff_gap(app_state, session, now_ms).await; + true +} + +pub(super) async fn mark_hls_provisioning_handoff_discontinuity_for_session(session: &HlsSessionHandle, now_ms: u64) { + let discontinuity_sequence = hls_provisioning_discontinuity_sequence(now_ms); + let proxy_session_id = { + let mut session = session.write().await; + session.mark_pending_handoff_discontinuity(discontinuity_sequence); + session.proxy_session_id.clone() + }; + debug!( + "HLS provisioning handoff discontinuity marked: proxy_session={} discontinuity_sequence={}", + safe_proxy_session_id(&proxy_session_id), + discontinuity_sequence + ); +} + +pub(super) fn clear_hls_provisioning_handoff_consumer( + app_state: &Arc, + input: &ConfigInput, + virtual_id: u32, + now_ms: u64, +) { + if !app_state.hls_provisioning.take_ready_slot_for_consumer(&input.name, virtual_id, now_ms) { + app_state.hls_provisioning.clear_consumer(&input.name, virtual_id); + } +} + +pub(super) async fn maybe_mark_hls_provisioning_handoff_for_canonical_manifest( + app_state: &Arc, + session: &HlsSessionHandle, + input: &ConfigInput, + virtual_id: u32, + access_lease_id: &HlsAccessLeaseId, + now_ms: u64, +) -> Option { + if !app_state.hls_provisioning.has_consumer(&input.name, virtual_id, now_ms) { + return None; + } + let previous_manifest_rendered_at_ms = latest_shared_hls_manifest_rendered_at_ms(session).await; + mark_hls_provisioning_handoff_discontinuity_once_for_session( + app_state, + session, + input, + virtual_id, + Some(access_lease_id), + now_ms, + ) + .await + .then_some(previous_manifest_rendered_at_ms) +} + +pub(super) async fn latest_shared_hls_manifest_rendered_at_ms(session: &HlsSessionHandle) -> u64 { + let session = session.read().await; + session + .last_rendered_manifest + .as_ref() + .map_or(0, |rendered| rendered.rendered_at_ms) + .max(session.transient.last_manifest_rendered_at_ms.unwrap_or(0)) +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::api) async fn hls_panel_provisioning_poll_manifest_response( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + target: &Arc, + input: &ConfigInput, + stream_identity: &HlsEntryStreamIdentity, + original_hls_entry_path: &str, + server_path: Option<&str>, +) -> axum::response::Response { + hls_panel_provisioning_poll_response( + app_state, + fingerprint, + user, + target, + input, + stream_identity, + original_hls_entry_path, + server_path, + HlsProvisioningPollResponseKind::Legacy, + ) + .await +} + +pub(super) enum HlsProvisioningPollResponseKind { + Legacy, +} + +impl HlsProvisioningPollResponseKind { + pub(super) fn access_lease_id(&self) -> Option<&HlsAccessLeaseId> { + match self { + Self::Legacy => None, + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn hls_panel_provisioning_poll_response( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + target: &Arc, + input: &ConfigInput, + stream_identity: &HlsEntryStreamIdentity, + ready_redirect_path: &str, + server_path: Option<&str>, + response_kind: HlsProvisioningPollResponseKind, +) -> axum::response::Response { + let virtual_id = stream_identity.virtual_id(); + let now_ms = current_time_millis(); + app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), virtual_id, now_ms); + + let existing_status = app_state.hls_provisioning.consumer_status(&input.name, virtual_id, now_ms); + + if try_reserve_hls_virtual_entry_origin_account_for_redirect( + app_state, + fingerprint, + user, + target, + input, + stream_identity, + ) + .await + { + mark_hls_provisioning_handoff_discontinuity( + app_state, + input, + stream_identity, + response_kind.access_lease_id(), + now_ms, + ) + .await; + clear_hls_provisioning_handoff_consumer(app_state, input, virtual_id, current_time_millis()); + return hls_virtual_entry_redirect_response(ready_redirect_path, server_path); + } + + let provisioning_enabled = can_provision_on_exhausted(app_state.as_ref(), input); + if provisioning_enabled { + start_hls_panel_provisioning_once(app_state, input); + } + + let status = existing_status.unwrap_or(if provisioning_enabled { + HlsProvisioningStatus::InProgress + } else { + HlsProvisioningStatus::ProviderExhausted + }); + + match status { + HlsProvisioningStatus::Ready | HlsProvisioningStatus::InProgress => { + hls_custom_video_manifest_response_with_virtual_id( + app_state, + user, + CustomVideoStreamType::Provisioning, + StatusCode::SERVICE_UNAVAILABLE, + Some(virtual_id), + ) + .await + } + HlsProvisioningStatus::ProviderExhausted => { + hls_custom_video_manifest_response_with_virtual_id( + app_state, + user, + CustomVideoStreamType::ProviderConnectionsExhausted, + StatusCode::SERVICE_UNAVAILABLE, + Some(virtual_id), + ) + .await + } + } +} + +pub(super) async fn hls_panel_provisioning_or_status_response( + app_state: &Arc, + user: &ProxyUserCredentials, + input: &ConfigInput, + virtual_id: u32, + _original_hls_entry_path: &str, + server_path: Option<&str>, + fallback_status: StatusCode, +) -> axum::response::Response { + try_hls_panel_provisioning_manifest_response( + app_state, + user, + input, + virtual_id, + HlsPanelProvisioningRedirectPaths { waiting_manifest_path: None }, + server_path, + fallback_status, + ) + .await + .unwrap_or_else(|| fallback_status.into_response()) +} + +#[derive(Debug, Clone)] +pub(super) struct SharedHlsProvisioningSegmentPlan { + pub(super) proxy_seq: u64, + pub(super) physical_index: usize, + pub(super) cache_key: SegmentCacheKey, + pub(super) segment_kind: SharedHlsProvisioningLocalSegmentKind, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum SharedHlsProvisioningLocalSegmentKind { + Provisioning, + Gap, +} + +pub(super) fn shared_hls_provisioning_segment_plans( + session: &HlsSession, + physical_segment_count: usize, +) -> Vec { + let existing_provisioning_segments = + session.segments.values().filter(|entry| is_hls_provisioning_segment(entry)).count(); + let append_count = if existing_provisioning_segments == 0 { 3 } else { 1 }; + let start_proxy_seq = session.proxy_next_seq.unwrap_or(0); + (0..append_count) + .filter_map(|offset| { + let proxy_seq = start_proxy_seq.checked_add(u64::try_from(offset).ok()?)?; + if session.segments.contains_key(&proxy_seq) { + return None; + } + Some(SharedHlsProvisioningSegmentPlan { + proxy_seq, + physical_index: (existing_provisioning_segments + offset) % physical_segment_count, + cache_key: SegmentCacheKey::new(session.proxy_session_id.clone(), proxy_seq, "ts"), + segment_kind: SharedHlsProvisioningLocalSegmentKind::Provisioning, + }) + }) + .collect() +} + +pub(super) fn shared_hls_provisioning_segment_entry( + plan: SharedHlsProvisioningSegmentPlan, + content_length: u64, + duration_ms: u64, + now_ms: u64, +) -> SegmentEntry { + let origin_epoch = match plan.segment_kind { + SharedHlsProvisioningLocalSegmentKind::Provisioning => HLS_PROVISIONING_ORIGIN_EPOCH, + SharedHlsProvisioningLocalSegmentKind::Gap => HLS_PROVISIONING_GAP_ORIGIN_EPOCH, + }; + SegmentEntry { + origin_key: OriginSegmentKey { + origin_epoch, + effective_host_id: 0, + host_local_sequence: plan.proxy_seq, + host_local_index: u32::try_from(plan.proxy_seq).unwrap_or(u32::MAX), + }, + proxy_seq: plan.proxy_seq, + duration_ms, + proxy_file_ext: "ts".to_string(), + content_type: "video/mp2t".to_string(), + cache_key: plan.cache_key, + discontinuity_before: false, + program_date_time: None, + daterange_tags_before: Vec::new(), + origin_byte_range: None, + map_ref: None, + encryption: None, + origin_fetch_ref: None, + status: SegmentCacheStatus::Ready { content_length, ready_at_ms: now_ms }, + last_rendered_at_ms: None, + access: Arc::new(CacheAccessState::new()), + } +} + +pub(super) async fn commit_shared_hls_provisioning_segments( + app_state: &Arc, + session: &HlsSessionHandle, + plans: &[SharedHlsProvisioningSegmentPlan], + provisioning_segments: &[TransportStreamBuffer], +) -> Option> { + let mut committed = Vec::with_capacity(plans.len()); + for plan in plans { + let video = provisioning_segments.get(plan.physical_index)?; + let duration_ms = video.duration_ms().unwrap_or(HLS_PROVISIONING_SEGMENT_DURATION_MS); + let metadata = match app_state + .hls_proxy + .segment_cache() + .write_bytes_and_commit(&plan.cache_key, video.as_bytes()) + .await + { + Ok(metadata) => metadata, + Err(err) => { + let safe_proxy_session = { + let session_guard = session.read().await; + safe_proxy_session_id(&session_guard.proxy_session_id) + }; + warn!( + "HLS provisioning segment cache commit failed for shared manifest: proxy_session={} seq={} error={err}", + safe_proxy_session, plan.proxy_seq + ); + return None; + } + }; + committed.push((plan.clone(), metadata.size, duration_ms)); + } + Some(committed) +} + +pub(super) async fn ensure_shared_hls_provisioning_handoff_gap( + app_state: &Arc, + session: &HlsSessionHandle, + now_ms: u64, +) -> bool { + let custom_stream_response = app_state.app_config.custom_stream_response.load(); + let Some(provisioning_segments) = custom_stream_response + .as_ref() + .map(|response| response.panel_api_provisioning_hls_segments.clone()) + .filter(|segments| !segments.is_empty()) + else { + return false; + }; + let plan = { + let session_guard = session.read().await; + if !session_guard.segments.values().any(is_hls_provisioning_segment) + || session_guard.segments.values().any(is_hls_provisioning_gap_segment) + { + return false; + } + let proxy_seq = session_guard.proxy_next_seq.unwrap_or(0); + if session_guard.segments.contains_key(&proxy_seq) { + return false; + } + let existing_provisioning_segments = + session_guard.segments.values().filter(|entry| is_hls_provisioning_segment(entry)).count(); + SharedHlsProvisioningSegmentPlan { + proxy_seq, + physical_index: existing_provisioning_segments % provisioning_segments.len(), + cache_key: SegmentCacheKey::new(session_guard.proxy_session_id.clone(), proxy_seq, "ts"), + segment_kind: SharedHlsProvisioningLocalSegmentKind::Gap, + } + }; + let Some(committed) = commit_shared_hls_provisioning_segments( + app_state, + session, + std::slice::from_ref(&plan), + &provisioning_segments, + ) + .await + else { + return false; + }; + let mut session_guard = session.write().await; + let mut inserted = false; + for (plan, content_length, duration_ms) in committed { + if session_guard.segments.contains_key(&plan.proxy_seq) { + continue; + } + if session_guard.publishable_origin_head_proxy_seq.is_none() { + session_guard.publishable_origin_head_proxy_seq = Some(plan.proxy_seq); + } + session_guard.publishable_origin_tail_proxy_seq = Some(plan.proxy_seq); + session_guard.proxy_next_seq = Some(plan.proxy_seq.saturating_add(1)); + session_guard + .segments + .insert(plan.proxy_seq, shared_hls_provisioning_segment_entry(plan, content_length, duration_ms, now_ms)); + inserted = true; + } + if inserted { + session_guard.target_duration = Some(HLS_PROVISIONING_TARGET_DURATION_SECS); + session_guard.independent_segments = true; + } + inserted +} + +pub(super) async fn hls_shared_provisioning_timeline_manifest_response( + app_state: &Arc, + session: &HlsSessionHandle, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &crate::model::StripConfig, + server_path: Option<&str>, +) -> Option { + let custom_stream_response = app_state.app_config.custom_stream_response.load(); + let provisioning_segments = custom_stream_response + .as_ref() + .map(|response| response.panel_api_provisioning_hls_segments.clone()) + .filter(|segments| !segments.is_empty())?; + let now_ms = current_time_millis(); + let plans = { + let session_guard = session.read().await; + shared_hls_provisioning_segment_plans(&session_guard, provisioning_segments.len()) + }; + if plans.is_empty() { + let mut session_guard = session.write().await; + session_guard.render_and_store_manifest(now_ms).ok()?; + } else { + let committed = + commit_shared_hls_provisioning_segments(app_state, session, &plans, &provisioning_segments).await?; + let mut session_guard = session.write().await; + for (plan, content_length, duration_ms) in committed { + if session_guard.segments.contains_key(&plan.proxy_seq) { + continue; + } + if session_guard.publishable_origin_head_proxy_seq.is_none() { + session_guard.publishable_origin_head_proxy_seq = Some(plan.proxy_seq); + } + session_guard.publishable_origin_tail_proxy_seq = Some(plan.proxy_seq); + session_guard.proxy_next_seq = Some(plan.proxy_seq.saturating_add(1)); + session_guard.segments.insert( + plan.proxy_seq, + shared_hls_provisioning_segment_entry(plan, content_length, duration_ms, now_ms), + ); + } + session_guard.target_duration = Some(HLS_PROVISIONING_TARGET_DURATION_SECS); + session_guard.independent_segments = true; + session_guard.render_and_store_manifest(now_ms).ok()?; + } + + try_hls_cached_manifest_response( + app_state, + session, + access_lease_id, + access_lease_state, + strip, + server_path, + HlsCachedManifestOptions::initial(Duration::ZERO), + HlsRuntimeBandwidthLearningContext::Disabled, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn hls_shared_provisioning_or_provider_exhausted_response( + app_state: &Arc, + session: &HlsSessionHandle, + username: &str, + input: &ConfigInput, + virtual_id: u32, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &crate::model::StripConfig, + server_path: Option<&str>, +) -> axum::response::Response { + let Some((_user, _target)) = app_state.app_config.get_target_for_username(username) else { + return hls_canonical_retry_after_response(); + }; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let now_ms = current_time_millis(); + let provisioning_enabled = can_provision_on_exhausted(app_state.as_ref(), input); + if provisioning_enabled { + app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), virtual_id, now_ms); + start_hls_panel_provisioning_once(app_state, input); + if let Some(HlsProvisioningStatus::ProviderExhausted) = + app_state.hls_provisioning.consumer_status(&input.name, virtual_id, now_ms) + { + return hls_runtime_or_standalone_custom_tail_response( + app_state, + session, + &proxy_session_id, + access_lease_id, + HlsRuntimeCustomTailReason::ProviderConnectionsExhausted, + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + } + if let Some(response) = hls_shared_provisioning_timeline_manifest_response( + app_state, + session, + access_lease_id, + access_lease_state, + strip, + server_path, + ) + .await + { + return response; + } + } + + let provider_exhausted_custom_response_available = is_custom_video_stream_enabled(&app_state.app_config) + && app_state + .app_config + .custom_stream_response + .load() + .as_ref() + .and_then(|response| response.provider_connections_exhausted.as_ref()) + .is_some(); + if provider_exhausted_custom_response_available { + return hls_runtime_or_standalone_custom_tail_response( + app_state, + session, + &proxy_session_id, + access_lease_id, + HlsRuntimeCustomTailReason::ProviderConnectionsExhausted, + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + } + hls_canonical_retry_after_response() +} + +pub(super) enum HlsProviderExhaustedResolution { + RetryAcquire, + Response(axum::response::Response), +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn hls_provider_connections_exhausted_manifest_resolution( + app_state: &Arc, + session: &HlsSessionHandle, + username: &str, + input: &ConfigInput, + virtual_id: u32, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &crate::model::StripConfig, + server_path: Option<&str>, + allow_grace_hold: bool, +) -> HlsProviderExhaustedResolution { + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let grace_options = app_state.get_grace_options(); + if allow_grace_hold && grace_options.hold_stream && grace_options.period_millis > 0 { + debug!( + "HLS provider connections exhausted; holding canonical manifest for grace: proxy_session={} lease={} hold_ms={}", + safe_proxy_session_id(&proxy_session_id), + safe_hls_access_lease_id(access_lease_id), + grace_options.period_millis + ); + let capacity_notify = app_state.connection_manager.capacity_notified(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(grace_options.period_millis); + let wake_reason = tokio::select! { + () = capacity_notify.notified() => "capacity-notified", + () = tokio::time::sleep_until(deadline) => "timeout", + }; + debug!( + "HLS provider connections exhausted grace hold completed: proxy_session={} lease={} reason={wake_reason}", + safe_proxy_session_id(&proxy_session_id), + safe_hls_access_lease_id(access_lease_id) + ); + return HlsProviderExhaustedResolution::RetryAcquire; + } + + HlsProviderExhaustedResolution::Response( + hls_shared_provisioning_or_provider_exhausted_response( + app_state, + session, + username, + input, + virtual_id, + access_lease_id, + access_lease_state, + strip, + server_path, + ) + .await, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_hls_canonical_manifest_origin_runtime( + app_state: &Arc, + session: &HlsSessionHandle, + context: &HlsAccessContext, + origin: &HlsCacheManifestOrigin<'_>, + path_proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + fingerprint: &Fingerprint, + server_path: Option<&str>, + now_ms: u64, +) -> Result> { + let mut allow_grace_hold = true; + loop { + let origin_policy = hls_effective_origin_acquire_policy(session).await; + match prepare_hls_origin_runtime( + app_state, + session, + origin.input, + origin.raw_request_url, + origin.session_entry_url.as_str(), + path_proxy_session_id, + fingerprint, + origin_policy.connection_kind, + origin_policy.priority, + HlsOriginWorkKind::Manifest, + HlsOriginWorkClass::ManifestInteractive, + now_ms, + ) + .await + { + Ok(prepared) => return Ok(prepared), + Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { + reason: HlsOriginRuntimeNoAccountReason::OriginBindingPreempted, + }) => { + return Err(Box::new( + hls_runtime_or_standalone_custom_tail_response( + app_state, + session, + path_proxy_session_id, + access_lease_id, + HlsRuntimeCustomTailReason::LowPriorityPreempted, + StatusCode::SERVICE_UNAVAILABLE, + ) + .await, + )); + } + Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { + reason: HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted, + }) => { + let strip = app_state.hls_proxy.strip(); + match hls_provider_connections_exhausted_manifest_resolution( + app_state, + session, + &context.username, + origin.input, + context.virtual_id, + access_lease_id, + access_lease_state, + &strip, + server_path, + allow_grace_hold, + ) + .await + { + HlsProviderExhaustedResolution::RetryAcquire => { + allow_grace_hold = false; + } + HlsProviderExhaustedResolution::Response(response) => return Err(Box::new(response)), + } + } + Err(HlsOriginRuntimeAcquireError::Fatal(status)) => { + return Err(Box::new(hls_canonical_status_response(status))) + } + } + } +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(super) async fn try_hls_cache_canonical_manifest_response( + app_state: &Arc, + fingerprint: &Fingerprint, + context: &HlsAccessContext, + path_proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + origin: HlsCacheManifestOrigin<'_>, + headers: HeaderMap, + server_path: Option<&str>, + _original_hls_entry_path: &str, + refresh_ordering: HlsManifestRefreshOrdering, +) -> Option { + if !hls_cache_configured(app_state) { + return None; + } + if origin.origin_source.input_id != context.input_id || origin.origin_source.stream_ref != context.stream_ref { + return Some(StatusCode::NOT_FOUND.into_response()); + } + + let session_key = origin.origin_source.session_key(); + let expected_proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + if &expected_proxy_session_id != path_proxy_session_id { + return Some(StatusCode::NOT_FOUND.into_response()); + } + let now_ms = current_time_millis(); + let rewrite_secret = app_state.get_encrypt_secret(); + let (session, session_outcome) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key, + origin.origin_source.clone(), + &rewrite_secret, + now_ms, + ) + .await; + if access_lease_state == HlsAccessLeaseState::Activated { + let timing = hls_access_lease_timing_for_session(app_state, &session).await; + match app_state + .hls_proxy + .touch_manifest_access_lease( + access_lease_id, + path_proxy_session_id, + now_ms, + Some(timing), + None, + hls_access_lease_ttl_ms(app_state), + ) + .await + { + HlsAccessLeaseTouch::Touched { .. } => {} + HlsAccessLeaseTouch::Denied => { + return Some( + hls_runtime_or_standalone_custom_tail_response( + app_state, + &session, + path_proxy_session_id, + access_lease_id, + HlsRuntimeCustomTailReason::UserConnectionsExhausted, + StatusCode::FORBIDDEN, + ) + .await, + ); + } + HlsAccessLeaseTouch::Expired | HlsAccessLeaseTouch::UnknownLease | HlsAccessLeaseTouch::SessionMismatch => { + return Some(StatusCode::NOT_FOUND.into_response()); + } + } + } + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + path_proxy_session_id, + now_ms, + ) + .await; + let prepared_origin = match prepare_hls_canonical_manifest_origin_runtime( + app_state, + &session, + context, + &origin, + path_proxy_session_id, + access_lease_id, + access_lease_state, + fingerprint, + server_path, + now_ms, + ) + .await + { + Ok(prepared) => prepared, + Err(response) => return Some(*response), + }; + let url_failover_provider = effective_hls_url_failover_provider_for_fetch_url( + &prepared_origin.fetch_url, + prepared_origin.url_failover_provider.clone(), + origin.session_entry_url.url_failover_provider(), + ); + let origin_entry = LiveHlsOriginEntry::parse_with_provider_configs( + &prepared_origin.fetch_url, + url_failover_provider, + prepared_origin.runtime_provider_config.clone(), + )?; + { + let mut session_guard = session.write().await; + if session_guard.is_gc_marked_for_removal() { + return Some(hls_canonical_retry_after_response()); + } + if prepared_origin.origin_account_binding_to_store.is_some() { + session_guard.replace_origin_account_binding(prepared_origin.origin_account_binding_to_store); + } + } + mark_hls_authorized_manifest_access(app_state, &session, now_ms).await; + let selected_account = session.read().await.origin_account_binding.as_ref().map_or_else( + || "".to_string(), + |binding| sanitize_sensitive_info(binding.account_name.as_ref()).to_string(), + ); + debug!( + "HLS origin account selected: proxy_session={} account={}", + safe_proxy_session_id(path_proxy_session_id), + selected_account + ); + let reservation_ttl_secs = hls_origin_account_reservation_ttl_secs_for_session(&session).await; + let previous_manifest_rendered_at_ms = latest_shared_hls_manifest_rendered_at_ms(&session).await; + let handoff_previous_rendered_at_ms = maybe_mark_hls_provisioning_handoff_for_canonical_manifest( + app_state, + &session, + origin.input, + context.virtual_id, + access_lease_id, + now_ms, + ) + .await; + let manifest_commit_requirement = + hls_manifest_commit_requirement(&session, session_outcome, handoff_previous_rendered_at_ms, now_ms).await; + let hls_ctx = app_state.hls_ctx(); + let acceptance_evaluation = + hls_manifest_acceptance_directive_for_session(&hls_ctx, &session, path_proxy_session_id).await; + let (acceptance_directive, availability_reevaluation_owner_key) = match acceptance_evaluation { + HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => (directive, None), + HlsManifestAcceptanceEvaluationOutcome::StateContention { owner_key } => { + (HlsManifestAcceptanceDirective::none(), Some(owner_key)) + } + HlsManifestAcceptanceEvaluationOutcome::SessionSuperseded => { + app_state + .connection_manager + .release_provider_handle(prepared_origin.preacquired_origin_account_handle) + .await; + return Some(hls_canonical_retry_after_response()); + } + }; + let manifest_boundary_rendered_at_ms = handoff_previous_rendered_at_ms.unwrap_or(previous_manifest_rendered_at_ms); + let wait_timeout = + hls_manifest_wait_timeout_for_requirement(app_state, &session, manifest_commit_requirement).await; + let cached_manifest_options = hls_cached_manifest_options_for_requirement( + wait_timeout, + manifest_commit_requirement, + manifest_boundary_rendered_at_ms, + ); + let bandwidth_learning = match context.known_bitrate_bps { + Some(_) => HlsRuntimeBandwidthLearningContext::Disabled, + None => HlsRuntimeBandwidthLearningContext::Eligible(origin.input), + }; + + let origin_policy = hls_effective_origin_acquire_policy(&session).await; + let origin_provider_session_headers = session.read().await.origin_provider_session_headers.clone(); + let mut preacquired_provider_handle = prepared_origin.preacquired_origin_account_handle; + let mut origin_io = HlsOriginIoContext { + ctx: hls_ctx.clone(), + client_addr: fingerprint.addr, + allow_grace: HlsOriginWorkClass::ManifestInteractive.allows_grace(), + priority: origin_policy.priority, + connection_kind: origin_policy.connection_kind, + reservation_ttl_secs, + preacquired_provider_handle: None, + started_generation: None, + }; + if availability_reevaluation_owner_key.is_none() { + if let Some(provider_handle) = preacquired_provider_handle.take() { + origin_io = origin_io.with_preacquired_provider_handle(provider_handle); + } + } + + let refresh_request = OriginRefreshRequest { + app_config: Arc::clone(&app_state.app_config), + session: Arc::clone(&session), + origin_entry, + headers, + origin_provider_session_headers, + client: app_state.http_client.load().as_ref().clone(), + no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), + use_manual_redirects: app_state.should_use_manual_redirects(), + segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), + hls_proxy: Arc::clone(&app_state.hls_proxy), + segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), + segment_worker_pool: Arc::clone(app_state.hls_proxy.segment_worker_pool()), + map_worker_pool: Arc::clone(app_state.hls_proxy.map_worker_pool()), + origin_manifest_timeout_ms: app_state.hls_proxy.origin_manifest_timeout_ms(), + manifest_recovery_burst: app_state.hls_proxy.manifest_recovery_burst(), + strip: app_state.hls_proxy.strip().clone(), + retry_policy: RetryPolicy::default(), + reverse_proxy_rewrite_secret: rewrite_secret.to_vec(), + transient_resource_ttl_ms: app_state.hls_proxy.transient_resource_ttl_ms(), + manifest_commit_requirement, + fresh_manifest_requirement_generation: None, + acceptance_directive, + access_lease_id: Some(access_lease_id.clone()), + disabled_headers: app_state.get_disabled_headers(), + now_ms, + origin_io: Some(origin_io), + post_refresh_runtime: Some(HlsPostRefreshRuntime { ctx: hls_ctx.downgrade() }), + }; + let refresh_ordering = if session_outcome == HlsSessionStoreOutcome::Reused { + refresh_ordering + } else { + HlsManifestRefreshOrdering::Background + }; + if let Some(owner_key) = availability_reevaluation_owner_key { + app_state.connection_manager.release_provider_handle(preacquired_provider_handle).await; + touch_initial_manifest_access_lease_window( + app_state, + access_lease_id, + path_proxy_session_id, + access_lease_state, + wait_timeout, + now_ms, + ) + .await; + let owner_wait_lease = app_state + .hls_proxy + .access_lease_response_snapshot(access_lease_id, path_proxy_session_id, current_time_millis()) + .await; + let expected_lease_issued_at_ms = owner_wait_lease.as_ref().map(|lease| lease.issued_at_ms); + let request_deadline_ms = owner_wait_lease + .as_ref() + .map_or(now_ms, |lease| hls_canonical_owner_request_deadline_ms(lease, wait_timeout, now_ms)); + let safe_session = { + let session = session.read().await; + safe_session_key(&session.key) + }; + let registration = + register_hls_availability_reevaluation(hls_ctx, Arc::clone(&session), owner_key, refresh_request); + return Some(match hls_canonical_owner_registration(registration) { + HlsCanonicalOwnerRegistration::Join(registration) => { + let strip = app_state.hls_proxy.strip(); + join_hls_canonical_manifest_owner( + HlsCanonicalOwnerHandoffContext { + app_state, + proxy_session_id: path_proxy_session_id, + access_lease_id, + expected_lease_issued_at_ms, + strip: &strip, + server_path, + manifest_commit_requirement, + manifest_boundary_rendered_at_ms, + bandwidth_learning, + request_deadline_ms, + safe_session, + }, + registration, + ) + .await + } + HlsCanonicalOwnerRegistration::FailClosed(failure) => { + hls_availability_reevaluation_registration_failure_response(failure) + } + }); + } + if handoff_previous_rendered_at_ms.is_some() { + touch_initial_manifest_access_lease_window( + app_state, + access_lease_id, + path_proxy_session_id, + access_lease_state, + wait_timeout, + now_ms, + ) + .await; + if let Some(response) = trigger_hls_canonical_manifest_refresh( + app_state, + &session, + path_proxy_session_id, + access_lease_id, + refresh_request, + refresh_ordering, + ) + .await + { + return Some(response); + } + let strip = app_state.hls_proxy.strip(); + if let Some(response) = try_hls_cached_manifest_response( + app_state, + &session, + access_lease_id, + access_lease_state, + &strip, + server_path, + cached_manifest_options, + bandwidth_learning, + ) + .await + { + clear_hls_provisioning_handoff_consumer(app_state, origin.input, context.virtual_id, current_time_millis()); + return Some(response); + } + return Some(StatusCode::SERVICE_UNAVAILABLE.into_response()); + } + match session_outcome { + HlsSessionStoreOutcome::Created => { + touch_initial_manifest_access_lease_window( + app_state, + access_lease_id, + path_proxy_session_id, + access_lease_state, + wait_timeout, + now_ms, + ) + .await; + if let Some(response) = trigger_hls_canonical_manifest_refresh( + app_state, + &session, + path_proxy_session_id, + access_lease_id, + refresh_request, + refresh_ordering, + ) + .await + { + return Some(response); + } + let strip = app_state.hls_proxy.strip(); + if let Some(response) = try_hls_cached_manifest_response( + app_state, + &session, + access_lease_id, + access_lease_state, + &strip, + server_path, + cached_manifest_options, + bandwidth_learning, + ) + .await + { + return Some(response); + } + } + HlsSessionStoreOutcome::Reused => { + if let Some(response) = trigger_hls_canonical_manifest_refresh( + app_state, + &session, + path_proxy_session_id, + access_lease_id, + refresh_request, + refresh_ordering, + ) + .await + { + if refresh_ordering == HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation + && response.status() == StatusCode::SERVICE_UNAVAILABLE + { + let strip = app_state.hls_proxy.strip(); + if let Some(live_response) = try_hls_cached_manifest_response( + app_state, + &session, + access_lease_id, + access_lease_state, + &strip, + server_path, + HlsCachedManifestOptions::initial(Duration::ZERO), + bandwidth_learning, + ) + .await + .filter(|candidate| candidate.status() == StatusCode::OK) + { + return Some(live_response); + } + } + return Some(response); + } + touch_initial_manifest_access_lease_window( + app_state, + access_lease_id, + path_proxy_session_id, + access_lease_state, + wait_timeout, + now_ms, + ) + .await; + let strip = app_state.hls_proxy.strip(); + if let Some(response) = try_hls_cached_manifest_response( + app_state, + &session, + access_lease_id, + access_lease_state, + &strip, + server_path, + cached_manifest_options, + bandwidth_learning, + ) + .await + { + return Some(response); + } + } + } + + Some(hls_unpublished_lease_channel_unavailable_response(app_state, path_proxy_session_id, access_lease_id).await) +} + +pub(super) fn hls_initial_manifest_decision_wait_timeout(app_state: &Arc) -> Duration { + Duration::from_secs(app_state.hls_proxy.initial_manifest_wait_timeout_secs()) +} + +pub(super) async fn hls_manifest_wait_timeout_for_requirement( + app_state: &Arc, + session: &HlsSessionHandle, + requirement: HlsManifestCommitRequirement, +) -> Duration { + match requirement { + HlsManifestCommitRequirement::FreshCommitRequired { .. } => { + hls_initial_manifest_decision_wait_timeout(app_state) + } + HlsManifestCommitRequirement::CommittedManifestAllowed => { + hls_initial_manifest_wait_timeout(app_state, session).await + } + } +} + +pub(super) async fn touch_initial_manifest_access_lease_window( + app_state: &Arc, + access_lease_id: &HlsAccessLeaseId, + proxy_session_id: &ProxySessionId, + access_lease_state: HlsAccessLeaseState, + wait_timeout: Duration, + now_ms: u64, +) { + if wait_timeout.is_zero() || access_lease_state != HlsAccessLeaseState::Pending { + return; + } + let wait_timeout_ms = duration_to_millis_saturating(wait_timeout); + let deadline_ms = now_ms.saturating_add(wait_timeout_ms.max(hls_pending_bootstrap_window_ms(app_state))); + let touch = app_state + .hls_proxy + .touch_manifest_access_lease( + access_lease_id, + proxy_session_id, + now_ms, + None, + Some(HlsAccessLeasePendingDeadline::Bootstrap { deadline_ms }), + hls_access_lease_ttl_ms(app_state), + ) + .await; + let failure = match touch { + HlsAccessLeaseTouch::Touched { .. } => return, + HlsAccessLeaseTouch::Expired => "expired", + HlsAccessLeaseTouch::Denied => "denied", + HlsAccessLeaseTouch::UnknownLease => "unknown-lease", + HlsAccessLeaseTouch::SessionMismatch => "session-mismatch", + }; + debug!( + "HLS initial manifest lease window not extended: lease={} proxy_session={} outcome={failure}", + safe_hls_access_lease_id(access_lease_id), + safe_proxy_session_id(proxy_session_id) + ); +} + +pub(super) async fn hls_initial_manifest_wait_timeout( + app_state: &Arc, + session: &HlsSessionHandle, +) -> Duration { + let session = session.read().await; + if matches!( + session.account_binding_protection(current_time_millis()), + HlsAccountBindingProtection::NoMediaYet | HlsAccountBindingProtection::Expired + ) { + hls_initial_manifest_decision_wait_timeout(app_state) + } else { + Duration::ZERO + } +} + +pub(super) struct HlsCachedManifestRead { + pub(super) transient_body: Option, + pub(super) rendered_body: Option, + pub(super) should_wait: bool, + pub(super) wait_for_initial_commit: bool, +} + +pub(super) struct HlsCachedTransientManifestRead { + pub(super) body: Arc, + pub(super) template: Arc, + pub(super) source_commit_identity: HlsManifestCommitIdentity, + pub(super) window_policy: HlsManifestWindowPolicy, + pub(super) finalized_manifest_generation: Option, + published_resource_ids: HlsPublishedTransientResourceIds, +} + +pub(super) async fn read_hls_cached_manifest( + session: &HlsSessionHandle, + options: HlsCachedManifestOptions, + started_at_ms: u64, +) -> HlsCachedManifestRead { + let session = session.read().await; + let now_ms = current_time_millis(); + let should_wait = session.initial_manifest_commit_work_pending(); + let committed_body = hls_committed_manifest_body_for_request(&session, options, started_at_ms, now_ms); + let (transient_body, rendered_body) = match committed_body { + Some(HlsCommittedManifestBody::Transient(body)) => ( + session.transient.last_manifest_template().zip(session.transient.last_manifest_commit_identity()).map( + |(template, source_commit_identity)| HlsCachedTransientManifestRead { + body, + template, + source_commit_identity, + window_policy: session.transient.last_manifest_window_policy(), + finalized_manifest_generation: session.transient.current_finalized_manifest_generation(), + published_resource_ids: session.transient.last_manifest_published_resource_ids(), + }, + ), + None, + ), + Some(HlsCommittedManifestBody::Normal(body)) => (None, Some(body)), + None => (None, None), + }; + let wait_for_initial_commit = hls_should_wait_for_initial_manifest_commit( + &session, + transient_body.is_some() || rendered_body.is_some(), + should_wait, + options, + now_ms, + ); + HlsCachedManifestRead { transient_body, rendered_body, should_wait, wait_for_initial_commit } +} + +pub(super) struct HlsCachedManifestViewContext<'a> { + pub(super) proxy_session_id: &'a ProxySessionId, + pub(super) access_lease_id: &'a HlsAccessLeaseId, + pub(super) access_lease_state: HlsAccessLeaseState, + pub(super) strip: &'a crate::model::StripConfig, + pub(super) server_path: Option<&'a str>, + pub(super) bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, +} + +#[derive(Clone, Copy)] +pub(super) enum HlsRuntimeBandwidthLearningContext<'a> { + Disabled, + Eligible(&'a ConfigInput), +} + +impl HlsCachedManifestViewContext<'_> { + pub(super) fn new<'a>( + proxy_session_id: &'a ProxySessionId, + access_lease_id: &'a HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &'a crate::model::StripConfig, + server_path: Option<&'a str>, + bandwidth_learning: HlsRuntimeBandwidthLearningContext<'a>, + ) -> HlsCachedManifestViewContext<'a> { + HlsCachedManifestViewContext { + proxy_session_id, + access_lease_id, + access_lease_state, + strip, + server_path, + bandwidth_learning, + } + } + + pub(super) fn materialize( + &self, + body: &str, + mode: &'static str, + window_policy: HlsManifestWindowPolicy, + ) -> HlsMaterializedSharedManifest { + materialize_shared_hls_access_manifest( + body, + self.access_lease_id, + self.access_lease_state, + self.strip, + window_policy, + mode, + self.server_path, + ) + } + + pub(super) async fn finish( + &self, + app_state: &Arc, + session: &HlsSessionHandle, + materialized: HlsMaterializedSharedManifest, + strip_diagnostic: HlsInitialStripPublicationDiagnostic, + ) -> axum::response::Response { + touch_pending_manifest_follow_up_window(app_state, session, self.access_lease_id, self.access_lease_state) + .await; + drop(spawn_hls_runtime_bandwidth_persistence(app_state, session, self.bandwidth_learning)); + mark_successful_canonical_manifest_activity(app_state, session, current_time_millis()).await; + log_hls_initial_strip_publication(self.proxy_session_id, self.access_lease_id, strip_diagnostic); + hls_response(materialized.body).into_response() + } +} + +pub(super) fn spawn_hls_runtime_bandwidth_persistence( + app_state: &Arc, + session: &HlsSessionHandle, + context: HlsRuntimeBandwidthLearningContext<'_>, +) -> Option> { + let input = match context { + HlsRuntimeBandwidthLearningContext::Disabled => return None, + HlsRuntimeBandwidthLearningContext::Eligible(input) => input.clone(), + }; + let (bitrate_bps, proxy_session_id, stream_ref) = { + let Ok(mut session_guard) = session.try_write() else { + return None; + }; + let bitrate_bps = session_guard.begin_bandwidth_persistence(current_time_millis())?; + (bitrate_bps, session_guard.proxy_session_id.clone(), session_guard.origin_source.stream_ref.clone()) + }; + let app_config = Arc::clone(&app_state.app_config); + let hls_proxy = Arc::clone(&app_state.hls_proxy); + let session = Arc::clone(session); + + Some(tokio::spawn(async move { + let outcome = match persist_input_live_bitrate_bps(&app_config, &input, &stream_ref, bitrate_bps).await { + Ok(repository_outcome) => hls_bandwidth_persistence_outcome(repository_outcome, &proxy_session_id), + Err(err) => { + error!( + "HLS runtime bandwidth persistence failed: proxy_session={} error={}", + safe_proxy_session_id(&proxy_session_id), + sanitize_sensitive_info(&err.to_string()) + ); + HlsBandwidthPersistenceOutcome::RetryAfter + } + }; + let Some(current_session) = hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await else { + return; + }; + if !Arc::ptr_eq(¤t_session, &session) { + return; + } + current_session.write().await.finish_bandwidth_persistence(bitrate_bps, outcome, current_time_millis()); + })) +} + +pub(super) fn hls_bandwidth_persistence_outcome( + repository_outcome: LiveBitratePersistenceOutcome, + proxy_session_id: &ProxySessionId, +) -> HlsBandwidthPersistenceOutcome { + match repository_outcome { + LiveBitratePersistenceOutcome::Updated | LiveBitratePersistenceOutcome::AlreadyEqualOrHigher => { + HlsBandwidthPersistenceOutcome::Persisted + } + LiveBitratePersistenceOutcome::MissingDatabase => { + debug!( + "HLS runtime bandwidth persistence deferred: proxy_session={} reason=missing_database", + safe_proxy_session_id(proxy_session_id) + ); + HlsBandwidthPersistenceOutcome::RetryAfter + } + LiveBitratePersistenceOutcome::MissingStreamItem => { + debug!( + "HLS runtime bandwidth persistence deferred: proxy_session={} reason=missing_stream_item", + safe_proxy_session_id(proxy_session_id) + ); + HlsBandwidthPersistenceOutcome::RetryAfter + } + LiveBitratePersistenceOutcome::PermanentlyInapplicable(reason) => { + debug!( + "HLS runtime bandwidth persistence skipped: proxy_session={} reason={}", + safe_proxy_session_id(proxy_session_id), + reason.log_label() + ); + HlsBandwidthPersistenceOutcome::PermanentlyInapplicable + } + } +} + +pub(super) fn hls_cached_manifest_temporarily_unavailable() -> axum::response::Response { + hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) +} + +pub(super) fn observe_hls_lease_manifest_snapshot_derivation( + app_state: &AppState, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + derivation: Result, HlsManifestLimitViolation>, +) -> Result, ()> { + match derivation { + Ok(snapshot) => { + if let Some(snapshot) = snapshot.as_ref() { + app_state.hls_proxy.metrics().record_lease_snapshot_segments(snapshot.visible_segments.len()); + } + Ok(snapshot) + } + Err(violation) => { + app_state.hls_proxy.metrics().record_manifest_limit_rejection(); + warn!( + "HLS lease manifest snapshot rejected: proxy_session={} lease={} reason=manifest-representation-limit kind={} actual={} limit={}", + safe_proxy_session_id(proxy_session_id), + safe_hls_access_lease_id(access_lease_id), + violation.kind.as_log_value(), + violation.actual, + violation.limit + ); + Err(()) + } + } +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(super) async fn try_hls_cached_manifest_response( + app_state: &Arc, + session: &HlsSessionHandle, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &crate::model::StripConfig, + server_path: Option<&str>, + options: HlsCachedManifestOptions, + bandwidth_learning: HlsRuntimeBandwidthLearningContext<'_>, +) -> Option { + let started_at = tokio::time::Instant::now(); + let started_at_ms = current_time_millis(); + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let Some(publication_guard) = app_state + .hls_proxy + .prepare_access_lease_manifest_publication(access_lease_id, &proxy_session_id, started_at_ms) + .await + else { + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + let view = HlsCachedManifestViewContext::new( + &proxy_session_id, + access_lease_id, + access_lease_state, + strip, + server_path, + bandwidth_learning, + ); + loop { + let cached = read_hls_cached_manifest(session, options, started_at_ms).await; + if !cached.wait_for_initial_commit { + let prepared = if let Some(transient) = cached.transient_body { + let materialized = view.materialize(&transient.body, "transient", transient.window_policy); + let published_resource_ids = if transient.window_policy.preserves_full_manifest() { + transient.published_resource_ids.clone() + } else { + HlsPublishedTransientResourceIds::from_manifest_body(&materialized.body) + }; + let delivered_at_ms = current_time_millis(); + let snapshot_input = if transient.window_policy.preserves_full_manifest() { + HlsLeaseManifestSnapshotInput::TransientPassthroughTemplate { + template: &transient.template, + source_commit_identity: transient.source_commit_identity, + uri_materialization: HlsLeaseManifestUriMaterialization::new( + access_lease_id, + normalize_hls_proxy_public_path_prefix(server_path).map(Arc::from), + ), + finalized_manifest_generation: transient.finalized_manifest_generation, + } + } else { + HlsLeaseManifestSnapshotInput::TransientPassthrough { + materialized_body: &materialized.body, + source_commit_identity: transient.source_commit_identity, + finalized_manifest_generation: transient.finalized_manifest_generation, + } + }; + let snapshot = observe_hls_lease_manifest_snapshot_derivation( + app_state, + &proxy_session_id, + access_lease_id, + derive_hls_lease_manifest_snapshot(&snapshot_input, delivered_at_ms), + ); + let Ok(snapshot) = snapshot else { + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + let Some(snapshot) = snapshot else { + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) + } else if let Some(body) = cached.rendered_body { + let materialized = view.materialize(&body, "normal", HlsManifestWindowPolicy::ApplyLiveWindow); + let published_resource_ids = HlsPublishedTransientResourceIds::from_manifest_body(&materialized.body); + let delivered_at_ms = current_time_millis(); + let snapshot = { + let session = session.read().await; + observe_hls_lease_manifest_snapshot_derivation( + app_state, + &proxy_session_id, + access_lease_id, + derive_hls_lease_manifest_snapshot( + &HlsLeaseManifestSnapshotInput::NormalCacheTimeline { + session: &session, + committed_body: &body, + materialized_body: &materialized.body, + stripped_tail_segments: stripped_tail_segments(&materialized), + }, + delivered_at_ms, + ), + ) + }; + let Ok(snapshot) = snapshot else { + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + let Some(snapshot) = snapshot else { + if access_lease_state != HlsAccessLeaseState::Pending { + return None; + } + if wait_for_hls_startup_evidence(started_at, options.wait_timeout).await { + continue; + } + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) + } else { + None + }; + if let Some((materialized, snapshot, published_resource_ids, delivered_at_ms)) = prepared { + if access_lease_state == HlsAccessLeaseState::Pending + && !hls_startup_admission_allows_snapshot(&app_state.hls_ctx(), session, &snapshot, delivered_at_ms) + .await + { + if wait_for_hls_startup_evidence(started_at, options.wait_timeout).await { + continue; + } + return Some(hls_cached_manifest_temporarily_unavailable()); + } + let startup_snapshot = snapshot.clone(); + let admission_at_ms = current_time_millis(); + let outcome = app_state + .hls_proxy + .commit_access_lease_manifest_publication_with_resources( + access_lease_id, + &proxy_session_id, + publication_guard, + snapshot, + published_resource_ids, + admission_at_ms, + ) + .await; + if let Some(snapshot_generation) = outcome.snapshot_generation() { + let published_at_ms = current_time_millis(); + let first_startup_publication = + app_state.hls_proxy.startup_observability().record_manifest_publication( + access_lease_id, + snapshot_generation, + admission_at_ms, + published_at_ms, + Arc::from(startup_snapshot.visible_proxy_seqs().collect::>()), + ); + if first_startup_publication && hls_access_manifest_uses_startup_view(access_lease_state) { + app_state.hls_proxy.spawn_access_lease_repair_prewarm( + Arc::clone(session), + access_lease_id.clone(), + startup_snapshot, + snapshot_generation, + ); + } + } + let publication_status = if outcome.is_committed() { + HlsInitialStripPublicationStatus::Committed + } else { + HlsInitialStripPublicationStatus::NotCommitted + }; + let Some(strip_diagnostic) = + hls_initial_strip_publication_diagnostic(publication_status, access_lease_state, &materialized) + else { + return Some(hls_cached_manifest_temporarily_unavailable()); + }; + return Some(view.finish(app_state, session, materialized, strip_diagnostic).await); + } + } + if options.wait_timeout.is_zero() || !cached.should_wait || started_at.elapsed() >= options.wait_timeout { + return None; + } + let remaining = options.wait_timeout.saturating_sub(started_at.elapsed()); + tokio::time::sleep(remaining.min(HLS_MANIFEST_WAIT_POLL_INTERVAL)).await; + } +} + +pub(super) async fn wait_for_hls_startup_evidence(started_at: tokio::time::Instant, wait_timeout: Duration) -> bool { + let elapsed = started_at.elapsed(); + if wait_timeout.is_zero() || elapsed >= wait_timeout { + return false; + } + tokio::time::sleep(wait_timeout.saturating_sub(elapsed).min(Duration::from_millis(25))).await; + true +} + +pub(super) async fn mark_successful_canonical_manifest_activity( + app_state: &Arc, + session: &HlsSessionHandle, + now_ms: u64, +) { + mark_hls_authorized_media_access(app_state, session, now_ms).await; +} + +pub(super) async fn mark_hls_authorized_manifest_access( + app_state: &Arc, + session: &HlsSessionHandle, + now_ms: u64, +) { + session.write().await.mark_authorized_manifest_access(now_ms); + app_state.hls_proxy.schedule_session_idle_for_handle(session).await; +} + +pub(super) async fn mark_hls_authorized_media_access( + app_state: &Arc, + session: &HlsSessionHandle, + now_ms: u64, +) { + session.write().await.mark_authorized_media_access(now_ms); + app_state.hls_proxy.schedule_session_idle_for_handle(session).await; +} + +pub(super) fn hls_cache_configured(app_state: &Arc) -> bool { + let config = app_state.app_config.config.load(); + config.reverse_proxy.as_ref().is_some_and(|reverse_proxy| reverse_proxy.hls_cache.is_some()) +} + +pub(super) fn hls_cache_enabled_for_target(app_state: &Arc, target: &ConfigTarget) -> bool { + hls_cache_configured(app_state) && is_hls_stream_share_enabled(target) +} + +pub(super) struct HlsAccessManifestRequestContext { + pub(super) input: Arc, + pub(super) hls_url: String, + pub(super) session_entry_url: HlsOriginEntryUrl, + pub(super) original_hls_entry_path: String, + pub(super) origin_source: HlsOriginSource, + pub(super) headers: HeaderMap, + pub(super) server_path: Option, +} + +pub(super) async fn resolve_hls_playback_manifest_request_context( + app_state: &Arc, + access_context: &HlsAccessContext, + req_headers: &HeaderMap, +) -> Result { + let Some((user, target)) = app_state.app_config.get_target_for_username(&access_context.username) else { + return Err(StatusCode::NOT_FOUND); + }; + if !hls_cache_enabled_for_target(app_state, &target) { + return Err(StatusCode::NOT_FOUND); + } + let Some(input) = app_state.app_config.get_input_by_id(access_context.input_id) else { + return Err(StatusCode::NOT_FOUND); + }; + if app_state + .active_users + .is_user_blocked_for_stream(&user.username, VirtualId::new(access_context.virtual_id)) + .await + { + return Err(StatusCode::FORBIDDEN); + } + let Some(channel) = get_stream_channel(app_state, &target, access_context.virtual_id).await else { + return Err(StatusCode::NOT_FOUND); + }; + let origin_playlist_url = if let Some(archive_url) = access_context.archive_origin_url.as_ref() { + archive_url.clone() + } else { + resolve_hls_origin_playlist_url(app_state, &target, &input, access_context.virtual_id, channel.url.as_ref()) + .await? + }; + let Some(hls_cache_origin) = build_hls_origin_resolution(&input, &origin_playlist_url) else { + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; + let origin_source = build_hls_origin_source_for_playback( + &input, + access_context.stream_ref.clone(), + access_context.epg_reference_ts, + Some(&origin_playlist_url), + ); + let Some(server_info) = app_state.app_config.get_user_server_info(&user) else { + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; + let disabled_headers = app_state.get_disabled_headers(); + let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); + let headers = build_hls_manifest_request_headers( + &input.headers, + req_headers, + disabled_headers.as_ref(), + default_user_agent.as_deref(), + channel.upstream_user_agent.as_deref(), + ); + + let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, access_context.virtual_id); + + Ok(HlsAccessManifestRequestContext { + input, + hls_url: hls_cache_origin.hls_url, + session_entry_url: hls_cache_origin.session_entry_url, + original_hls_entry_path, + origin_source, + headers, + server_path: server_info.path.clone(), + }) +} + +pub(super) async fn hls_manifest_preflight_refresh_ordering( + app_state: &Arc, + session: &HlsSessionHandle, + lease: &HlsAccessLease, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + now_ms: u64, +) -> Result> { + match hls_manifest_terminal_preflight(session, lease, now_ms).await { + HlsManifestTerminalPreflight::ServeCommittedPlayback => { + Err(Box::new(hls_terminal_playback_response(lease, proxy_session_id, access_lease_id).unwrap_or_else( + || hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::RuntimeUnavailable), + ))) + } + HlsManifestTerminalPreflight::BootstrapPendingLease => Ok(HlsManifestRefreshOrdering::Background), + HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation => { + Ok(HlsManifestRefreshOrdering::AwaitBeforeTerminalEvaluation) + } + HlsManifestTerminalPreflight::EvaluateTerminal => resolve_hls_terminal_manifest_state( + app_state, + session, + proxy_session_id, + access_lease_id, + lease.clone(), + now_ms, + ) + .await + .map(|_| HlsManifestRefreshOrdering::Background), + HlsManifestTerminalPreflight::FailClosed { reason } => { + Err(Box::new(hls_terminal_failed_closed_response(reason))) + } + } +} + +pub(super) async fn hls_proxy_manifest( + fingerprint: Fingerprint, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, + headers: HeaderMap, +) -> axum::response::Response { + let proxy_session_id = ProxySessionId(params.proxy_session_id); + let access_lease_id = HlsAccessLeaseId(params.hls_access_lease_id); + let now_ms = current_time_millis(); + let access_lease_snapshot = + app_state.hls_proxy.access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms).await; + if let Some(lease) = access_lease_snapshot.as_ref() { + let standalone_policy_response_required = lease.playback_mode == HlsLeasePlaybackMode::Ended + && lease + .runtime_policy_denial_reason() + .is_some_and(HlsRuntimeCustomTailReason::permits_unpublished_lease_standalone_tail); + if let (false, Some(response)) = ( + standalone_policy_response_required, + hls_terminal_playback_response(lease, &proxy_session_id, &access_lease_id), + ) { + return response; + } + } + let session = app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await; + if let Some(session) = session.as_ref() { + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + session, + &proxy_session_id, + now_ms, + ) + .await; + } + let (access_context, access_lease_state) = match hls_manifest_access_context_and_state( + &app_state, + &fingerprint, + &proxy_session_id, + &access_lease_id, + access_lease_snapshot.as_ref(), + now_ms, + ) + .await + { + Ok(context_and_state) => context_and_state, + Err(response) => return *response, + }; + let refresh_ordering = if let (Some(session), Some(lease)) = (session.as_ref(), access_lease_snapshot.as_ref()) { + match hls_manifest_preflight_refresh_ordering( + &app_state, + session, + lease, + &proxy_session_id, + &access_lease_id, + now_ms, + ) + .await + { + Ok(ordering) => ordering, + Err(response) => return *response, + } + } else { + HlsManifestRefreshOrdering::Background + }; + let request_context = + match resolve_hls_playback_manifest_request_context(&app_state, &access_context, &headers).await { + Ok(context) => context, + Err(status) => return hls_canonical_status_response(status), + }; + + try_hls_cache_canonical_manifest_response( + &app_state, + &fingerprint, + &access_context, + &proxy_session_id, + &access_lease_id, + access_lease_state, + HlsCacheManifestOrigin { + raw_request_url: &request_context.hls_url, + session_entry_url: request_context.session_entry_url.clone(), + input: &request_context.input, + origin_source: request_context.origin_source, + }, + request_context.headers, + request_context.server_path.as_deref(), + &request_context.original_hls_entry_path, + refresh_ordering, + ) + .await + .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()) +} diff --git a/backend/app/src/api/endpoints/hls_api/mod.rs b/backend/app/src/api/endpoints/hls_api/mod.rs new file mode 100644 index 000000000..3440f3b5f --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/mod.rs @@ -0,0 +1,213 @@ +#![allow(clippy::large_futures)] + +// Route shared Xtream URL helpers through the one-way `xtream_url` boundary so +// this module does not import a sibling endpoint directly. +use super::{ + hls_terminal_response::{ + hls_manifest_terminal_preflight, hls_response, hls_temporary_resource_unavailable_response, + hls_terminal_failed_closed_response, hls_terminal_playback_response, resolve_hls_terminal_manifest_state, + terminal_segment_get_response, terminal_segment_head_response, terminal_segment_immutable_replay_response, + terminal_tail_plan_for_current_route, HlsManifestTerminalPreflight, + }, + xtream_url::{get_query_path, get_xtream_player_api_stream_url, ApiStreamContext}, +}; +use crate::{ + api::{ + api_utils::{ + connection_priority_for_kind, create_api_proxy_user, create_m3u_catchup_session_key, + create_playback_session_fingerprint, create_session_fingerprint, force_provider_stream_response, + get_headers_from_request, get_hls_session_ttl_secs, get_stream_alternative_url, + is_hls_stream_share_enabled, is_seekable_media_request, local_stream_response, + record_connect_failed_attempt, resolve_playback_request_admission, try_option_bad_request, try_unwrap_body, + ConnectFailedAttempt, EvictionReentryGuard, HeaderFilter, + }, + model::{ + hls_cache::initial_strip::{ + materialize_initial_hls_strip_view, HlsInitialStripOutcome, HlsInitialStripSkipReason, + }, + hls_custom_video_manifest_response_for_access_lease, hls_custom_video_manifest_response_with_virtual_id, + hls_provisioning_discontinuity_sequence, hls_virtual_entry_redirect_response, + is_custom_video_stream_enabled, start_hls_panel_provisioning_once, + try_hls_panel_provisioning_manifest_response, AppState, ConnectionHistoryMode, CustomVideoStreamType, + GraceMode, HlsPanelProvisioningRedirectPaths, HlsProvisioningStatus, ProviderAllocation, + ProviderConfig as RuntimeProviderConfig, ProviderHandle, StreamMeterHandle, TransportStreamBuffer, + UserSession, + }, + panel_api::can_provision_on_exhausted, + }, + auth::{check_network_access_only, Fingerprint}, + model::{ + ConfigInput, ConfigInputFlags, ConfigProvider, ConfigTarget, InputSource, ProxyUserCredentials, + ReverseProxyDisabledHeaderConfig, + }, + processing::parser::hls::{ + get_hls_session_token_and_url_from_token, origin_manifest::HlsManifestWindowPolicy, rewrite_hls, + RewriteHlsProps, + }, + repository::{ + load_input_live_bitrate_bps, m3u_get_item_for_stream_id, persist_input_live_bitrate_bps, storage_const, + xtream_get_item_for_stream_id, LiveBitratePersistenceOutcome, + }, + utils::{content_coding::OutboundContentCodingPolicy, debug_if_enabled, request, request::is_file_url}, +}; +use axum::{ + body::Body, + http::{header, HeaderMap, HeaderValue, Method, StatusCode}, + response::IntoResponse, +}; +use futures::FutureExt; +use log::{debug, error, warn}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use shared::{ + defaults::HLS_EXT, + model::{ + ConnectFailureReason, FailureStage, InputType, PlaylistEntry, PlaylistItemType, StreamChannel, StreamInfo, + StreamProperties, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, + }, + utils::{ + extract_extension_from_url, generate_random_string, is_hls_url, is_m3u_catchup_session_token, + replace_url_extension, sanitize_sensitive_info, Internable, PROVIDER_SCHEME_PREFIX, + }, +}; +use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; +use tuliprox_core::utils::current_time_millis; +use tuliprox_hls::{ + api::{ + begin_hls_origin_account_io_bounded, build_hls_origin_session_owner, build_proxy_session_id, + cold_start_retry_after_seconds, commit_hls_runtime_custom_tail, derive_hls_lease_manifest_snapshot, + extract_hls_provider_session_headers, fetch_and_commit_hls_transient_origin_response_with_attempt_prepare, + fetch_hls_transient_origin_response_with_attempt_prepare, finite_hls_terminal_key_response, + force_identity_without_range, hls_cached_manifest_options_for_requirement, + hls_committed_manifest_body_for_request, hls_manifest_acceptance_directive_for_session, + hls_manifest_commit_requirement, hls_object_body_deadline, hls_origin_account_status, + hls_should_wait_for_initial_manifest_commit, hls_startup_admission_allows_snapshot, + hls_transient_object_fetch_failure, hls_transient_origin_response, is_hls_provisioning_gap_segment, + is_hls_provisioning_segment, maybe_trigger_origin_refresh_with_outcome, new_hls_access_lease_id, + origin_account_binding_from_allocation, record_successful_transient_segment_fetch, + record_temporary_transient_segment_fetch_failure, register_hls_availability_reevaluation, + resolve_hls_transient_object_cache_action, safe_hls_access_lease_id, safe_proxy_session_id, safe_session_key, + safe_user_session_token, scrub_hls_origin_headers, serve_hls_map_cache_outcome, + serve_hls_segment_cache_outcome, serve_hls_transient_object_cache_outcome, + serve_hls_transient_object_cache_response, should_remove_hls_origin_header, trigger_origin_refresh_sync, + validate_hls_access_lease, CacheAccessState, HlsAccessAdmissionMode, HlsAccessContext, HlsAccessLease, + HlsAccessLeaseActivation, HlsAccessLeaseId, HlsAccessLeasePendingDeadline, HlsAccessLeaseState, + HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsAccessLeaseValidationError, HlsAccountBindingProtection, + HlsAccountOverlapTiming, HlsAvailabilityReevaluationObservation, HlsAvailabilityReevaluationRegistration, + HlsBandwidthPersistenceOutcome, HlsBoundAccountAcquireErrorKind, HlsCacheResponseContext, + HlsCachedManifestOptions, HlsCommittedManifestBody, HlsEffectiveOriginAcquirePolicy, HlsLeaseManifestSnapshot, + HlsLeaseManifestSnapshotInput, HlsLeaseManifestUriMaterialization, HlsLeasePlaybackMode, + HlsLeaseStartupAdmissionState, HlsLogIdentity, HlsManifestAcceptanceDirective, + HlsManifestAcceptanceEvaluationOutcome, HlsManifestCommitIdentity, HlsManifestCommitRequirement, + HlsManifestLimitViolation, HlsMapFile, HlsMasterBandwidth, HlsMasterBandwidthSelection, + HlsMediaActivityCommitOutcome, HlsMediaActivityMarker, HlsMediaLeaseIdentity, HlsOriginAccountBinding, + HlsOriginAccountBindingMode, HlsOriginAccountDetachedReason, HlsOriginAccountStatus, HlsOriginIoContext, + HlsOriginRefreshTriggerOutcome, HlsOriginResourceClients, HlsOriginResourceFetchError, HlsOriginSource, + HlsOriginSourceKind, HlsOriginWorkClass, HlsPlaybackFamilyKey, HlsPostRefreshRuntime, + HlsPublishedTransientResourceIds, HlsQosMeterInit, HlsQosRuntimeConfig, HlsResourceFetchAttempt, + HlsResourceServeFailure, HlsResourceServeOutcome, HlsRuntimeCustomTailOutcome, HlsRuntimeCustomTailReason, + HlsRuntimeCustomTailRequest, HlsSegmentFile, HlsSession, HlsSessionHandle, HlsSessionKey, HlsSessionMode, + HlsSessionStoreOutcome, HlsSingleVariantMasterPlaylist, HlsTerminalFailedClosedReason, HlsTerminalSegmentPath, + HlsTransientCacheCommitContext, HlsTransientDecodedOriginResponse, HlsTransientDirectResponseContext, + HlsTransientManifestTemplate, HlsTransientObjectCacheAction, HlsTransientObjectFetchFailure, + HlsTransientObjectFetchFinalizer, HlsTransientOriginCacheFetchRequest, HlsTransientOriginFetchRequest, + HlsTransientOriginIoGuard, HlsTransientResourceLeaseContext, LiveHlsOriginEntry, OriginRefreshRequest, + OriginSegmentKey, ProxySessionId, RetryPolicy, SegmentCacheKey, SegmentCacheStatus, SegmentDemandFetchOutcome, + SegmentEntry, SegmentFetchContext, SegmentFetchPolicy, TransientManifestGeneration, TransientObjectFetchToken, + TransientObjectUnavailableState, TransientPassthroughState, TransientResourceFile, TransientResourceId, + TransientResourceRef, HLS_ACCESS_LEASE_ID_PLACEHOLDER, HLS_PROVISIONING_GAP_ORIGIN_EPOCH, + HLS_PROVISIONING_ORIGIN_EPOCH, HLS_PROVISIONING_SEGMENT_DURATION_MS, HLS_PROVISIONING_TARGET_DURATION_SECS, + MAX_HLS_MANIFEST_BYTES, + }, + HlsCtx, MAX_MANUAL_REDIRECTS, +}; +use url::Url; + +pub(super) const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS: u64 = 1; + +pub(super) const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS: u64 = HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS * 1_000; + +/// Poll interval while waiting for a canonical manifest commit. Lower values +/// reduce time-to-first-manifest at the cost of more wakeups per waiting client. +pub(super) const HLS_MANIFEST_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Debug, Deserialize)] +pub(super) struct HlsApiPathParams { + pub(super) username: String, + pub(super) password: String, + pub(super) target_id: u16, + pub(super) input_id: u16, + pub(super) stream_id: u32, + /// Single obfuscated token, or a leaked relative origin path (`dvr-YYYY/...ts`). + pub(super) token: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HlsProxySegmentPathParams { + pub(super) proxy_session_id: String, + pub(super) hls_access_lease_id: String, + pub(super) segment_file: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HlsProxyManifestPathParams { + pub(super) proxy_session_id: String, + pub(super) hls_access_lease_id: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HlsProxyMapPathParams { + pub(super) proxy_session_id: String, + pub(super) hls_access_lease_id: String, + pub(super) map_file: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HlsProxyResourcePathParams { + pub(super) proxy_session_id: String, + pub(super) hls_access_lease_id: String, + pub(super) resource_file: String, +} + +mod catchup; +mod manifest; +mod segment; +mod session; + +pub(in crate::api) use catchup::*; +pub(in crate::api) use manifest::*; +pub(in crate::api) use segment::*; +pub(in crate::api) use session::*; + +pub fn hls_api_register() -> axum::Router> { + axum::Router::new() + .route( + "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/manifest.m3u8", + axum::routing::get(hls_proxy_manifest), + ) + .route( + "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/terminal/{generation}/{terminal_file}", + axum::routing::get(hls_proxy_terminal_segment), + ) + .route( + "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/{segment_file}", + axum::routing::get(hls_proxy_segment), + ) + .route( + "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/map/{map_file}", + axum::routing::get(hls_proxy_map), + ) + .route( + "/hls/shared/live/{proxy_session_id}/{hls_access_lease_id}/r/{resource_file}", + axum::routing::get(hls_proxy_resource), + ) + .route( + "/hls/{username}/{password}/{target_id}/{input_id}/{stream_id}/{*token}", + axum::routing::get(hls_api_stream), + ) + //cfg.service(web::resource("/hls/{token}/{stream}").route(web::get().to(xtream_player_api_hls_stream))); + //cfg.service(web::resource("/play/{token}/{type}").route(web::get().to(xtream_player_api_play_stream))); +} + +#[cfg(test)] +mod tests; diff --git a/backend/app/src/api/endpoints/hls_api/segment.rs b/backend/app/src/api/endpoints/hls_api/segment.rs new file mode 100644 index 000000000..cdc163eb6 --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/segment.rs @@ -0,0 +1,3052 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +pub(super) fn log_hls_initial_strip_publication( + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, + diagnostic: HlsInitialStripPublicationDiagnostic, +) { + match diagnostic { + HlsInitialStripPublicationDiagnostic::Applied { mode, strip_mode, configured, effective, visible_segments } => { + debug!( + "HLS initial strip applied: mode={} lease={} proxy_session={} strip_mode={} configured={} effective={} visible_segments={}", + mode, + safe_hls_access_lease_id(lease_id), + safe_proxy_session_id(proxy_session_id), + strip_mode, + configured, + effective, + visible_segments + ); + } + HlsInitialStripPublicationDiagnostic::Skipped { mode, reason, visible_segments } => { + debug!( + "HLS initial strip skipped: mode={} lease={} proxy_session={} reason={} visible_segments={}", + mode, + safe_hls_access_lease_id(lease_id), + safe_proxy_session_id(proxy_session_id), + reason.as_log_reason(), + visible_segments + ); + } + HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { mode, reason } => { + debug!( + "HLS initial strip skipped: mode={} lease={} proxy_session={} reason={}", + mode, + safe_hls_access_lease_id(lease_id), + safe_proxy_session_id(proxy_session_id), + reason.as_log_reason() + ); + } + } +} + +pub(super) fn stripped_tail_segments(materialized: &HlsMaterializedSharedManifest) -> usize { + materialized.initial_strip_outcome.as_ref().map_or(0, |outcome| match outcome { + HlsInitialStripOutcome::Applied { effective, .. } => *effective, + HlsInitialStripOutcome::Skipped { .. } => 0, + }) +} + +pub(super) fn hls_access_lease_ttl_ms(app_state: &Arc) -> u64 { + app_state.hls_proxy.session_idle_timeout_ms() +} + +pub(super) fn duration_to_millis_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +pub(super) fn hls_pending_bootstrap_window_ms(app_state: &Arc) -> u64 { + duration_to_millis_saturating(hls_initial_manifest_decision_wait_timeout(app_state)) +} + +pub(super) async fn hls_access_lease_timing_for_session( + app_state: &Arc, + session: &HlsSessionHandle, +) -> HlsAccessLeaseTiming { + let timing = session.read().await.account_overlap_timing(); + let active_window_ms = timing.hard_active_window_ms.saturating_mul(2); + HlsAccessLeaseTiming { active_window_ms, valid_window_ms: hls_access_lease_ttl_ms(app_state) } +} + +pub(super) async fn touch_pending_manifest_follow_up_window( + app_state: &Arc, + session: &HlsSessionHandle, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, +) { + if access_lease_state != HlsAccessLeaseState::Pending { + return; + } + let (proxy_session_id, target_duration) = { + let session = session.read().await; + (session.proxy_session_id.clone(), session.target_duration) + }; + let now_ms = current_time_millis(); + if !app_state + .hls_proxy + .mark_pending_manifest_follow_up_for_lease(access_lease_id, &proxy_session_id, now_ms, target_duration) + .await + { + debug!( + "HLS pending manifest follow-up skipped: lease={} proxy_session={} reason=expired-or-generation-race", + safe_hls_access_lease_id(access_lease_id), + safe_proxy_session_id(&proxy_session_id) + ); + } +} + +pub(super) struct HlsResourceAccess { + pub(super) session: HlsSessionHandle, + pub(super) access_context: HlsAccessContext, + pub(super) lease: HlsAccessLease, +} + +pub(super) async fn prepare_hls_resource_access( + app_state: &Arc, + fingerprint: &Fingerprint, + proxy_session_id: &ProxySessionId, + hls_access_lease_id: &str, + now_ms: u64, + request_kind: &'static str, +) -> Result> { + let Some(session) = app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await else { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + }; + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + proxy_session_id, + now_ms, + ) + .await; + let access_context = match validate_hls_proxy_access_request( + app_state, + fingerprint, + proxy_session_id, + hls_access_lease_id, + now_ms, + hls_access_lease_timing_for_session(app_state, &session).await, + request_kind, + ) + .await + { + Ok(context) => context, + Err(err) => { + return Err(Box::new(hls_resource_access_lease_validation_response(&err))); + } + }; + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + proxy_session_id, + now_ms, + ) + .await; + reclaim_hls_account_overlap_if_needed(app_state, &session, now_ms).await; + let Some(lease) = + app_state.hls_proxy.access_lease_response_snapshot(&access_context.lease_id, proxy_session_id, now_ms).await + else { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + }; + Ok(HlsResourceAccess { session, access_context, lease }) +} + +pub(super) fn hls_lease_allows_live_origin_work(lease: &HlsAccessLease) -> bool { + matches!(lease.state, HlsAccessLeaseState::Pending | HlsAccessLeaseState::Activated | HlsAccessLeaseState::Idle) + && lease.playback_mode == HlsLeasePlaybackMode::Live +} + +pub(super) fn hls_lease_allows_cached_segment(lease: &HlsAccessLease, proxy_seq: u64) -> bool { + match &lease.playback_mode { + HlsLeasePlaybackMode::Live => true, + HlsLeasePlaybackMode::TerminalTail(plan) => plan.protected_base_proxy_seqs.contains(&proxy_seq), + HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => false, + } +} + +pub(super) async fn current_hls_resource_lease( + app_state: &Arc, + access_context: &HlsAccessContext, +) -> Option { + app_state + .hls_proxy + .access_lease_response_snapshot( + &access_context.lease_id, + &access_context.proxy_session_id, + current_time_millis(), + ) + .await +} + +pub(super) async fn hls_live_lease_identity_is_current( + app_state: &Arc, + access_context: &HlsAccessContext, + expected_identity: HlsMediaLeaseIdentity, +) -> bool { + current_hls_resource_lease(app_state, access_context).await.is_some_and(|lease| { + lease.playback_mode == HlsLeasePlaybackMode::Live && lease.media_identity() == Some(expected_identity) + }) +} + +pub(super) fn create_hls_cache_user_session_token( + fingerprint: &Fingerprint, + username: &str, + virtual_id: u32, + existing_session_token: Option<&str>, + archive_reference: Option, +) -> String { + let base = + hls_entry_user_session_token(fingerprint, username, virtual_id, existing_session_token, archive_reference); + format!("{base}|hls-cache|{}", generate_random_string(16)) +} + +pub(super) fn is_hls_media_activity_status(status: StatusCode) -> bool { + matches!(status, StatusCode::OK | StatusCode::PARTIAL_CONTENT) +} + +pub(super) async fn hls_cache_response_context( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + lease_identity: HlsMediaLeaseIdentity, + now_ms: u64, +) -> HlsCacheResponseContext { + let qos_meter = app_state.hls_proxy.qos().meter_for_access_lease(&access_context.lease_id).await; + let log_identity = { + let session = session.read().await; + HlsLogIdentity::from_session(&session) + }; + HlsCacheResponseContext::new( + access_context.lease_id.clone(), + log_identity, + app_state.hls_proxy.cache_duration_seconds(), + Arc::clone(app_state.hls_proxy.metrics()), + Arc::clone(app_state.hls_proxy.segment_repair()), + qos_meter, + Some(HlsMediaActivityMarker::new( + Arc::clone(&app_state.hls_proxy), + Arc::clone(session), + access_context.proxy_session_id.clone(), + access_context.lease_id.clone(), + lease_identity, + )), + now_ms, + ) +} + +pub(super) fn hls_qos_meter_init( + app_state: &Arc, + qos_config: HlsQosRuntimeConfig, +) -> Option { + if !qos_config.live_metering_enabled { + return None; + } + let meter_uid = app_state.connection_manager.next_stream_uid(); + let meter = Arc::new(StreamMeterHandle::new(meter_uid, Arc::downgrade(&app_state.event_manager))); + Some(HlsQosMeterInit { meter_uid, meter }) +} + +pub(super) async fn register_hls_cache_stream_for_successful_media_response( + app_state: &Arc, + fingerprint: &Fingerprint, + headers: &HeaderMap, + access_context: &HlsAccessContext, + session: &HlsSessionHandle, + response_context: &HlsCacheResponseContext, +) { + if ensure_hls_cache_stream_registered(app_state, fingerprint, headers, access_context, session).await.is_none() { + debug!( + "HLS media registration skipped: lease={} reason=session-or-connection-unavailable", + safe_hls_access_lease_id(&access_context.lease_id) + ); + } + response_context.set_qos_meter(app_state.hls_proxy.qos().meter_for_access_lease(&access_context.lease_id).await); +} + +pub(super) async fn hls_proxy_segment( + fingerprint: Fingerprint, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, + headers: HeaderMap, +) -> axum::response::Response { + let proxy_session_id = ProxySessionId(params.proxy_session_id); + let now_ms = current_time_millis(); + let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( + &app_state, + &fingerprint, + &proxy_session_id, + ¶ms.hls_access_lease_id, + now_ms, + "segment", + ) + .await + { + Ok(access) => access, + Err(response) => return *response, + }; + let Some(segment_file) = HlsSegmentFile::parse(¶ms.segment_file) else { + return hls_resource_channel_unavailable_response(&app_state, &access_context); + }; + if !hls_lease_allows_cached_segment(&lease, segment_file.proxy_seq) { + return StatusCode::NOT_FOUND.into_response(); + } + let allows_origin_work = hls_lease_allows_live_origin_work(&lease); + if let Err(response) = + validate_hls_segment_entry(&app_state, &session, &access_context, &segment_file, allows_origin_work).await + { + return *response; + } + let demand_result = if allows_origin_work { + demand_fetch_hls_live_segment( + &app_state, + &session, + &segment_file, + &access_context, + &fingerprint, + &headers, + now_ms, + ) + .await + } else { + Ok(()) + }; + if let Err(response) = demand_result { + return *response; + } + + serve_hls_segment_for_current_lease( + &app_state, + &session, + &access_context, + &fingerprint, + &headers, + segment_file, + now_ms, + ) + .await +} + +pub(super) async fn validate_hls_segment_entry( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + segment_file: &HlsSegmentFile, + allows_origin_work: bool, +) -> Result<(), Box> { + let session = session.read().await; + if session.is_gc_marked_for_removal() { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + } + let Some(entry) = session.segments.get(&segment_file.proxy_seq) else { + return Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))); + }; + if entry.proxy_file_ext != segment_file.extension { + return Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))); + } + if !allows_origin_work && !matches!(&entry.status, SegmentCacheStatus::Ready { .. }) { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + } + Ok(()) +} + +pub(super) async fn demand_fetch_hls_live_segment( + app_state: &Arc, + session: &HlsSessionHandle, + segment_file: &HlsSegmentFile, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + headers: &HeaderMap, + now_ms: u64, +) -> Result<(), Box> { + let preacquired_provider_handle = if hls_segment_request_requires_origin_work(session, segment_file).await { + match prepare_hls_origin_binding_for_authorized_resource_work( + app_state, + session, + access_context, + fingerprint, + headers, + HlsOriginWorkKind::Segment, + now_ms, + ) + .await + { + Ok(handle) => handle, + Err(err) => { + return Err(Box::new(hls_origin_runtime_resource_failure_response(app_state, access_context, err))) + } + } + } else { + None + }; + match demand_fetch_hls_segment_if_needed( + app_state, + session, + segment_file, + access_context, + fingerprint, + preacquired_provider_handle, + now_ms, + ) + .await + { + SegmentDemandFetchOutcome::NotFound => { + Err(Box::new(hls_resource_channel_unavailable_response(app_state, access_context))) + } + SegmentDemandFetchOutcome::Ready + | SegmentDemandFetchOutcome::QueuedOrFetching + | SegmentDemandFetchOutcome::Unavailable + | SegmentDemandFetchOutcome::TimedOut => Ok(()), + } +} + +pub(super) async fn serve_hls_segment_for_current_lease( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + headers: &HeaderMap, + segment_file: HlsSegmentFile, + now_ms: u64, +) -> axum::response::Response { + let Some(current_lease) = current_hls_resource_lease(app_state, access_context).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !hls_lease_allows_cached_segment(¤t_lease, segment_file.proxy_seq) { + return StatusCode::NOT_FOUND.into_response(); + } + let current_allows_origin_work = hls_lease_allows_live_origin_work(¤t_lease); + let Some(lease_identity) = current_lease.media_identity() else { + return StatusCode::NOT_FOUND.into_response(); + }; + let response_context = hls_cache_response_context(app_state, session, access_context, lease_identity, now_ms).await; + let response = hls_resource_serve_outcome_response( + app_state, + access_context, + serve_hls_segment_cache_outcome( + Arc::clone(app_state.hls_proxy.segment_cache()), + Arc::clone(session), + segment_file, + headers.get(header::RANGE).cloned(), + &response_context, + ) + .await, + ); + if current_allows_origin_work && is_hls_media_activity_status(response.status()) { + register_hls_cache_stream_for_successful_media_response( + app_state, + fingerprint, + headers, + access_context, + session, + &response_context, + ) + .await; + } + response +} + +pub(super) async fn hls_proxy_terminal_segment( + fingerprint: Fingerprint, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, + method: Method, + headers: HeaderMap, +) -> axum::response::Response { + let proxy_session_id = ProxySessionId(params.proxy_session_id); + let Some(path) = HlsTerminalSegmentPath::parse(¶ms.generation, ¶ms.terminal_file) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let now_ms = current_time_millis(); + let access_lease_id = HlsAccessLeaseId(params.hls_access_lease_id.clone()); + let immutable_replay_plan = + app_state.hls_proxy.access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms).await.and_then( + |lease| match lease.playback_mode { + HlsLeasePlaybackMode::TerminalTail(plan) if plan.matches_route(&proxy_session_id, &access_lease_id) => { + Some(plan) + } + HlsLeasePlaybackMode::Live + | HlsLeasePlaybackMode::TerminalTail(_) + | HlsLeasePlaybackMode::TerminalUnavailable { .. } + | HlsLeasePlaybackMode::Ended => None, + }, + ); + let access = prepare_hls_resource_access( + &app_state, + &fingerprint, + &proxy_session_id, + ¶ms.hls_access_lease_id, + now_ms, + "terminal-segment", + ) + .await; + let Ok(HlsResourceAccess { session, access_context, lease }) = access else { + return immutable_replay_plan + .and_then(|plan| { + terminal_segment_immutable_replay_response( + &plan, + path, + headers.get(header::RANGE), + method == Method::HEAD, + ) + }) + .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); + }; + let Some(current_lease) = current_hls_resource_lease(&app_state, &access_context).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some(plan) = + terminal_tail_plan_for_current_route(&lease, ¤t_lease, &proxy_session_id, &access_context.lease_id) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + if method == Method::HEAD { + return terminal_segment_head_response(&plan, path, headers.get(header::RANGE)) + .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); + } + let Some(lease_identity) = current_lease.media_identity() else { + return StatusCode::NOT_FOUND.into_response(); + }; + let response_context = + hls_cache_response_context(&app_state, &session, &access_context, lease_identity, now_ms).await; + let Some(response) = + terminal_segment_get_response(&plan, path, headers.get(header::RANGE), &response_context, &proxy_session_id) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + if is_hls_media_activity_status(response.status()) { + response_context.mark_media_activity().await; + register_hls_cache_stream_for_successful_media_response( + &app_state, + &fingerprint, + &headers, + &access_context, + &session, + &response_context, + ) + .await; + } + response +} + +pub(super) async fn demand_fetch_hls_segment_if_needed( + app_state: &Arc, + session: &HlsSessionHandle, + segment_file: &HlsSegmentFile, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + preacquired_provider_handle: Option, + now_ms: u64, +) -> SegmentDemandFetchOutcome { + let context = build_hls_segment_fetch_context( + app_state, + session, + Some(access_context.lease_id.clone()), + fingerprint, + preacquired_provider_handle, + ) + .await; + app_state.hls_proxy.segment_worker_pool().demand_fetch_and_wait(context, segment_file, now_ms).await +} + +pub(super) async fn build_hls_segment_fetch_context( + app_state: &Arc, + session: &HlsSessionHandle, + repair_access_lease_id: Option, + fingerprint: &Fingerprint, + preacquired_provider_handle: Option, +) -> SegmentFetchContext { + let (headers, origin_provider_session_headers, origin_policy, reservation_ttl_secs) = { + let session = session.read().await; + ( + session.origin_request_headers.clone(), + session.origin_provider_session_headers.clone(), + session.effective_origin_acquire_policy_or_default(), + session.account_overlap_timing().reservation_ttl_secs(), + ) + }; + let mut origin_io = HlsOriginIoContext { + ctx: app_state.hls_ctx(), + client_addr: fingerprint.addr, + allow_grace: HlsOriginWorkClass::Demand.allows_grace(), + priority: origin_policy.priority, + connection_kind: origin_policy.connection_kind, + reservation_ttl_secs, + preacquired_provider_handle: None, + started_generation: None, + }; + if let Some(provider_handle) = preacquired_provider_handle { + origin_io = origin_io.with_preacquired_provider_handle(provider_handle); + } + SegmentFetchContext { + session: Arc::clone(session), + segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), + segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), + repair_access_lease_id, + headers, + origin_provider_session_headers, + client: app_state.http_client.load().as_ref().clone(), + no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), + use_manual_redirects: app_state.should_use_manual_redirects(), + origin_io: Some(origin_io), + } +} + +pub(super) async fn hls_effective_origin_acquire_policy(session: &HlsSessionHandle) -> HlsEffectiveOriginAcquirePolicy { + session.read().await.effective_origin_acquire_policy_or_default() +} + +pub(super) async fn hls_origin_account_reservation_ttl_secs_for_session(session: &HlsSessionHandle) -> u64 { + session.read().await.account_overlap_timing().reservation_ttl_secs() +} + +pub(super) fn hls_origin_account_reservation_ttl_secs_fallback() -> u64 { + HlsAccountOverlapTiming::from_target_duration_secs(None).reservation_ttl_secs() +} + +pub(super) async fn hls_proxy_map( + fingerprint: Fingerprint, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, + headers: HeaderMap, +) -> axum::response::Response { + let proxy_session_id = ProxySessionId(params.proxy_session_id); + let now_ms = current_time_millis(); + let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( + &app_state, + &fingerprint, + &proxy_session_id, + ¶ms.hls_access_lease_id, + now_ms, + "map", + ) + .await + { + Ok(access) => access, + Err(response) => return *response, + }; + if !hls_lease_allows_live_origin_work(&lease) { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(map_file) = HlsMapFile::parse(¶ms.map_file) else { + return hls_resource_channel_unavailable_response(&app_state, &access_context); + }; + { + let session_guard = session.read().await; + if session_guard.is_gc_marked_for_removal() { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(entry) = session_guard.maps.get(&map_file.proxy_map_id.into()) else { + return hls_resource_channel_unavailable_response(&app_state, &access_context); + }; + if entry.proxy_file_ext != map_file.extension { + return hls_resource_channel_unavailable_response(&app_state, &access_context); + } + } + + let Some(current_lease) = current_hls_resource_lease(&app_state, &access_context).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + if current_lease.playback_mode != HlsLeasePlaybackMode::Live { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(lease_identity) = current_lease.media_identity() else { + return StatusCode::NOT_FOUND.into_response(); + }; + let response_context = + hls_cache_response_context(&app_state, &session, &access_context, lease_identity, now_ms).await; + let response = hls_resource_serve_outcome_response( + &app_state, + &access_context, + serve_hls_map_cache_outcome( + Arc::clone(app_state.hls_proxy.segment_cache()), + Arc::clone(&session), + map_file, + headers.get(header::RANGE).cloned(), + &response_context, + ) + .await, + ); + if is_hls_media_activity_status(response.status()) { + register_hls_cache_stream_for_successful_media_response( + &app_state, + &fingerprint, + &headers, + &access_context, + &session, + &response_context, + ) + .await; + } + response +} + +pub(super) async fn hls_proxy_resource( + fingerprint: Fingerprint, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, + headers: HeaderMap, +) -> axum::response::Response { + let proxy_session_id = ProxySessionId(params.proxy_session_id); + let now_ms = current_time_millis(); + let HlsResourceAccess { session, access_context, lease } = match prepare_hls_resource_access( + &app_state, + &fingerprint, + &proxy_session_id, + ¶ms.hls_access_lease_id, + now_ms, + "resource", + ) + .await + { + Ok(access) => access, + Err(response) => return *response, + }; + let Some(resource_file) = TransientResourceFile::parse(¶ms.resource_file) else { + return hls_resource_channel_unavailable_response(&app_state, &access_context); + }; + let Some(lease_identity) = lease.media_identity() else { + return StatusCode::NOT_FOUND.into_response(); + }; + let context = HlsResourceEndpointContext { + app_state: &app_state, + session: &session, + fingerprint: &fingerprint, + headers: &headers, + access_context: &access_context, + lease_identity, + published_resource_ids: lease.published_transient_resource_ids().clone(), + resource_file, + range_header: headers.get(header::RANGE).cloned(), + now_ms, + }; + match &lease.playback_mode { + HlsLeasePlaybackMode::Live => serve_hls_live_transient_resource(context).await, + HlsLeasePlaybackMode::TerminalTail(_) => serve_hls_terminal_key_resource(context, &lease.playback_mode).await, + HlsLeasePlaybackMode::TerminalUnavailable { .. } | HlsLeasePlaybackMode::Ended => { + StatusCode::NOT_FOUND.into_response() + } + } +} + +pub(super) struct HlsResourceEndpointContext<'a> { + pub(super) app_state: &'a Arc, + pub(super) session: &'a HlsSessionHandle, + pub(super) fingerprint: &'a Fingerprint, + pub(super) headers: &'a HeaderMap, + pub(super) access_context: &'a HlsAccessContext, + pub(super) lease_identity: HlsMediaLeaseIdentity, + published_resource_ids: HlsPublishedTransientResourceIds, + pub(super) resource_file: TransientResourceFile, + pub(super) range_header: Option, + pub(super) now_ms: u64, +} + +pub(super) async fn serve_hls_terminal_key_resource( + context: HlsResourceEndpointContext<'_>, + playback_mode: &HlsLeasePlaybackMode, +) -> axum::response::Response { + let HlsLeasePlaybackMode::TerminalTail(plan) = playback_mode else { + return StatusCode::NOT_FOUND.into_response(); + }; + let proxy_session_id = &context.access_context.proxy_session_id; + let Some(binding) = + plan.terminal_key_binding(proxy_session_id, &context.access_context.lease_id, &context.resource_file) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !context.session.read().await.terminal_key_binding_is_current( + &context.access_context.lease_id, + plan.generation, + &binding, + ) { + return StatusCode::NOT_FOUND.into_response(); + } + let response_context = hls_cache_response_context( + context.app_state, + context.session, + context.access_context, + context.lease_identity, + context.now_ms, + ) + .await; + let response = finite_hls_terminal_key_response( + binding.bytes(), + context.range_header.as_ref(), + binding.content_type(), + "private, max-age=300, immutable", + &response_context, + proxy_session_id, + context.resource_file.resource_id.0, + ); + if is_hls_media_activity_status(response.status()) { + response_context.mark_media_activity().await; + register_hls_cache_stream_for_successful_media_response( + context.app_state, + context.fingerprint, + context.headers, + context.access_context, + context.session, + &response_context, + ) + .await; + } + response +} + +pub(super) async fn serve_hls_live_transient_resource( + context: HlsResourceEndpointContext<'_>, +) -> axum::response::Response { + let cache_duration_ms = context.app_state.hls_proxy.cache_duration_seconds().saturating_mul(1_000); + let Ok(cache_resolution) = resolve_hls_transient_object_cache_action( + context.session, + &context.access_context.proxy_session_id, + HlsTransientResourceLeaseContext { + access_lease_id: &context.access_context.lease_id, + lease_issued_at_ms: context.lease_identity.lease_issued_at_ms(), + published_resource_ids: &context.published_resource_ids, + }, + &context.resource_file, + context.range_header.as_ref(), + context.now_ms, + cache_duration_ms, + ) + .await + else { + return hls_resource_channel_unavailable_response(context.app_state, context.access_context); + }; + let resource = cache_resolution.resource; + let origin_headers = cache_resolution.origin_headers; + let origin_provider_session_headers = cache_resolution.origin_provider_session_headers; + let cache_action = cache_resolution.action; + + match cache_action { + HlsTransientObjectCacheAction::ServeReady => { + return serve_transient_object_cache_response_and_mark_or_unavailable(TransientObjectCacheServeContext { + app_state: context.app_state, + session: context.session, + fingerprint: context.fingerprint, + headers: context.headers, + access_context: context.access_context, + lease_identity: context.lease_identity, + resource_file: context.resource_file, + range_header: context.range_header, + now_ms: context.now_ms, + }) + .await; + } + HlsTransientObjectCacheAction::WaitForFetch(notifier) => { + return wait_for_transient_object_cache_fetch(TransientObjectWaitContext { + app_state: context.app_state, + session: context.session, + fingerprint: context.fingerprint, + headers: context.headers, + access_context: context.access_context, + lease_identity: context.lease_identity, + resource_file: context.resource_file, + range_header: context.range_header, + notifier, + }) + .await; + } + HlsTransientObjectCacheAction::FetchAndCache(_) | HlsTransientObjectCacheAction::PassthroughNoCache => {} + } + + fetch_or_passthrough_transient_resource(HlsTransientPassthroughContext { + endpoint: context, + resource, + cache_action, + origin_headers, + origin_provider_session_headers, + cache_duration_ms, + }) + .await +} + +pub(super) struct HlsTransientPassthroughContext<'a> { + pub(super) endpoint: HlsResourceEndpointContext<'a>, + pub(super) resource: TransientResourceRef, + pub(super) cache_action: HlsTransientObjectCacheAction, + pub(super) origin_headers: HeaderMap, + pub(super) origin_provider_session_headers: HeaderMap, + pub(super) cache_duration_ms: u64, +} + +pub(super) async fn fetch_or_passthrough_transient_resource( + context: HlsTransientPassthroughContext<'_>, +) -> axum::response::Response { + let HlsTransientPassthroughContext { + endpoint, + resource, + cache_action, + origin_headers, + origin_provider_session_headers, + cache_duration_ms, + } = context; + if let HlsTransientObjectCacheAction::FetchAndCache(fetch_token) = cache_action { + return fetch_and_cache_transient_origin_response(HlsTransientEndpointCacheFetchContext { + app_state: endpoint.app_state, + session: endpoint.session, + fingerprint: endpoint.fingerprint, + headers: endpoint.headers, + access_context: endpoint.access_context, + lease_identity: endpoint.lease_identity, + resource: &resource, + resource_file: endpoint.resource_file, + fetch_token: *fetch_token, + origin_headers, + origin_provider_session_headers, + range_header: endpoint.range_header, + cache_duration_ms, + }) + .await; + } + + let policy = endpoint.app_state.hls_proxy.segment_fetch_policy(); + let fetch_result = fetch_transient_origin_response_with_provider_io(HlsTransientEndpointOriginFetchRequest { + app_state: endpoint.app_state, + session: endpoint.session, + access_context: endpoint.access_context, + fingerprint: endpoint.fingerprint, + headers: endpoint.headers, + resource: &resource, + resource_file: &endpoint.resource_file, + origin_headers, + origin_provider_session_headers, + range_header: endpoint.range_header.clone(), + policy: policy.clone(), + }) + .await; + serve_hls_transient_passthrough_result(endpoint, resource, policy, fetch_result).await +} + +pub(super) async fn serve_hls_transient_passthrough_result( + endpoint: HlsResourceEndpointContext<'_>, + resource: TransientResourceRef, + policy: SegmentFetchPolicy, + fetch_result: HlsTransientOriginFetchResult, +) -> axum::response::Response { + match fetch_result.result { + Ok(response) => { + if response.decoded.status.is_success() { + let activity_outcome = endpoint + .app_state + .hls_proxy + .mark_authorized_media_access_for_lease_if_identity_matches( + endpoint.session, + &endpoint.access_context.lease_id, + &endpoint.access_context.proxy_session_id, + endpoint.lease_identity, + endpoint.now_ms, + ) + .await; + match activity_outcome { + HlsMediaActivityCommitOutcome::Committed => {} + HlsMediaActivityCommitOutcome::StaleLeaseIdentity => { + debug!( + "HLS transient media response discarded: lease={} proxy_session={} reason=playback-generation-race", + safe_hls_access_lease_id(&endpoint.access_context.lease_id), + safe_proxy_session_id(&endpoint.access_context.proxy_session_id) + ); + return StatusCode::NOT_FOUND.into_response(); + } + HlsMediaActivityCommitOutcome::DeferredLockContention => { + debug!( + "HLS transient media response deferred: lease={} proxy_session={} reason=lock-contention", + safe_hls_access_lease_id(&endpoint.access_context.lease_id), + safe_proxy_session_id(&endpoint.access_context.proxy_session_id) + ); + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + } + if ensure_hls_cache_stream_registered( + endpoint.app_state, + endpoint.fingerprint, + endpoint.headers, + endpoint.access_context, + endpoint.session, + ) + .await + .is_none() + { + debug!( + "HLS transient media registration skipped: lease={} reason=session-or-connection-unavailable", + safe_hls_access_lease_id(&endpoint.access_context.lease_id) + ); + } + } + hls_transient_origin_response( + response, + HlsTransientDirectResponseContext { + session: Arc::clone(endpoint.session), + resource, + policy: policy.clone(), + now_ms: endpoint.now_ms, + log_identity: { + let session = endpoint.session.read().await; + HlsLogIdentity::from_session(&session) + }, + }, + ) + } + Err(err) => { + if matches!(err, HlsOriginResourceFetchError::ProviderUnavailable(_)) { + if let Some(runtime_err) = fetch_result.runtime_prepare_error { + return hls_origin_runtime_resource_failure_response( + endpoint.app_state, + endpoint.access_context, + runtime_err, + ); + } + } + match hls_transient_object_fetch_failure(&err) { + HlsTransientObjectFetchFailure::Retryable => { + let failed_at_ms = current_time_millis(); + if record_temporary_transient_segment_fetch_failure( + endpoint.session, + &resource, + &policy, + failed_at_ms, + ) + .await + { + hls_resource_channel_unavailable_response(endpoint.app_state, endpoint.access_context) + } else { + hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) + } + } + HlsTransientObjectFetchFailure::Permanent { status: _ } => { + hls_resource_channel_unavailable_response(endpoint.app_state, endpoint.access_context) + } + } + } + } +} + +pub(super) struct HlsTransientEndpointOriginFetchRequest<'a> { + pub(super) app_state: &'a Arc, + pub(super) session: &'a HlsSessionHandle, + pub(super) access_context: &'a HlsAccessContext, + pub(super) fingerprint: &'a Fingerprint, + pub(super) headers: &'a HeaderMap, + pub(super) resource: &'a TransientResourceRef, + pub(super) resource_file: &'a TransientResourceFile, + pub(super) origin_headers: HeaderMap, + pub(super) origin_provider_session_headers: HeaderMap, + pub(super) range_header: Option, + pub(super) policy: SegmentFetchPolicy, +} + +pub(super) struct HlsTransientOriginFetchResult { + pub(super) result: + Result>, HlsOriginResourceFetchError>, + pub(super) runtime_prepare_error: Option, +} + +pub(super) async fn fetch_transient_origin_response_with_provider_io( + request: HlsTransientEndpointOriginFetchRequest<'_>, +) -> HlsTransientOriginFetchResult { + let clients = HlsOriginResourceClients { + client: request.app_state.http_client.load().as_ref().clone(), + no_redirect_client: request.app_state.http_client_no_redirect.load().as_ref().clone(), + use_manual_redirects: request.app_state.should_use_manual_redirects(), + }; + let log_identity = { + let session = request.session.read().await; + HlsLogIdentity::from_session(&session) + }; + let fetch_request = HlsTransientOriginFetchRequest { + resolved_origin_uri: request.resource.resolved_origin_uri.clone(), + origin_headers: request.origin_headers, + origin_provider_session_headers: request.origin_provider_session_headers, + range_header: request.range_header, + resource_file: request.resource_file.clone(), + resource_kind: request.resource.kind, + clients, + policy: request.policy, + log_identity, + }; + let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); + let prepare_attempt = hls_transient_origin_prepare_closure( + request.app_state, + request.session, + request.access_context, + request.fingerprint, + request.headers, + &runtime_prepare_error, + ); + let result = fetch_hls_transient_origin_response_with_attempt_prepare(fetch_request, prepare_attempt).await; + let runtime_prepare_error = *runtime_prepare_error.lock().await; + HlsTransientOriginFetchResult { result, runtime_prepare_error } +} + +/// Builds the shared per-attempt prepare closure for transient origin fetches. +/// Runtime acquire failures are captured in `runtime_prepare_error` and mapped +/// to a provider-unavailable fetch error so the retry loop can proceed uniformly. +pub(super) fn hls_transient_origin_prepare_closure( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + headers: &HeaderMap, + runtime_prepare_error: &Arc>>, +) -> impl FnMut( + HlsResourceFetchAttempt, +) -> futures::future::BoxFuture< + 'static, + Result, HlsOriginResourceFetchError>, +> { + let app_state = Arc::clone(app_state); + let session = Arc::clone(session); + let access_context = access_context.clone(); + let fingerprint = fingerprint.clone(); + let headers = headers.clone(); + let runtime_prepare_error = Arc::clone(runtime_prepare_error); + move |_attempt| { + let app_state = Arc::clone(&app_state); + let session = Arc::clone(&session); + let access_context = access_context.clone(); + let fingerprint = fingerprint.clone(); + let headers = headers.clone(); + let runtime_prepare_error = Arc::clone(&runtime_prepare_error); + async move { + match prepare_hls_transient_origin_io_for_authorized_resource_work( + &app_state, + &session, + &access_context, + &fingerprint, + &headers, + current_time_millis(), + ) + .await + { + Ok(guard) => Ok(guard), + Err(err) => { + *runtime_prepare_error.lock().await = Some(err); + Err(HlsOriginResourceFetchError::ProviderUnavailable(HlsBoundAccountAcquireErrorKind::Unavailable)) + } + } + } + .boxed() + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) struct HlsTransientEndpointCacheFetchContext<'a> { + pub(super) app_state: &'a Arc, + pub(super) session: &'a HlsSessionHandle, + pub(super) fingerprint: &'a Fingerprint, + pub(super) headers: &'a HeaderMap, + pub(super) access_context: &'a HlsAccessContext, + pub(super) lease_identity: HlsMediaLeaseIdentity, + pub(super) resource: &'a TransientResourceRef, + pub(super) resource_file: TransientResourceFile, + pub(super) fetch_token: TransientObjectFetchToken, + pub(super) origin_headers: HeaderMap, + pub(super) origin_provider_session_headers: HeaderMap, + pub(super) range_header: Option, + pub(super) cache_duration_ms: u64, +} + +pub(super) struct TransientObjectWaitContext<'a> { + pub(super) app_state: &'a Arc, + pub(super) session: &'a HlsSessionHandle, + pub(super) fingerprint: &'a Fingerprint, + pub(super) headers: &'a HeaderMap, + pub(super) access_context: &'a HlsAccessContext, + pub(super) lease_identity: HlsMediaLeaseIdentity, + pub(super) resource_file: TransientResourceFile, + pub(super) range_header: Option, + pub(super) notifier: Arc, +} + +pub(super) struct TransientObjectCacheServeContext<'a> { + pub(super) app_state: &'a Arc, + pub(super) session: &'a HlsSessionHandle, + pub(super) fingerprint: &'a Fingerprint, + pub(super) headers: &'a HeaderMap, + pub(super) access_context: &'a HlsAccessContext, + pub(super) lease_identity: HlsMediaLeaseIdentity, + pub(super) resource_file: TransientResourceFile, + pub(super) range_header: Option, + pub(super) now_ms: u64, +} + +pub(super) async fn serve_transient_object_cache_response_and_mark( + context: TransientObjectCacheServeContext<'_>, +) -> axum::response::Response { + if !hls_live_lease_identity_is_current(context.app_state, context.access_context, context.lease_identity).await { + return StatusCode::NOT_FOUND.into_response(); + } + let response_context = hls_cache_response_context( + context.app_state, + context.session, + context.access_context, + context.lease_identity, + context.now_ms, + ) + .await; + let response = hls_resource_serve_outcome_response( + context.app_state, + context.access_context, + serve_hls_transient_object_cache_outcome( + Arc::clone(context.app_state.hls_proxy.segment_cache()), + Arc::clone(context.session), + context.resource_file, + context.range_header, + &response_context, + ) + .await, + ); + if is_hls_media_activity_status(response.status()) { + register_hls_cache_stream_for_successful_media_response( + context.app_state, + context.fingerprint, + context.headers, + context.access_context, + context.session, + &response_context, + ) + .await; + } + response +} + +pub(super) async fn serve_transient_object_cache_response_and_mark_or_unavailable( + context: TransientObjectCacheServeContext<'_>, +) -> axum::response::Response { + serve_transient_object_cache_response_and_mark(context).await +} + +pub(super) async fn wait_for_transient_object_cache_fetch( + context: TransientObjectWaitContext<'_>, +) -> axum::response::Response { + let wait_timeout = context.app_state.hls_proxy.segment_fetch_policy().origin_object_wait_timeout(); + let safe_resource_id = safe_transient_resource_id(&context.resource_file.resource_id); + debug!( + "HLS transient object wait started: resource_id={} lease={} state=inflight", + safe_resource_id, + safe_hls_access_lease_id(&context.access_context.lease_id) + ); + let wait_result = tokio::time::timeout(wait_timeout, context.notifier.notified()).await; + if wait_result.is_err() { + debug!( + "HLS transient object wait timed out: resource_id={} lease={} state=inflight", + safe_resource_id, + safe_hls_access_lease_id(&context.access_context.lease_id) + ); + return hls_transient_object_unavailable_response( + context.app_state, + context.session, + &context.resource_file, + current_time_millis(), + context.access_context, + ) + .await; + } + let response = serve_transient_object_cache_response_and_mark_or_unavailable(TransientObjectCacheServeContext { + app_state: context.app_state, + session: context.session, + fingerprint: context.fingerprint, + headers: context.headers, + access_context: context.access_context, + lease_identity: context.lease_identity, + resource_file: context.resource_file, + range_header: context.range_header, + now_ms: current_time_millis(), + }) + .await; + debug!( + "HLS transient object wait completed: resource_id={} lease={} status={}", + safe_resource_id, + safe_hls_access_lease_id(&context.access_context.lease_id), + response.status() + ); + response +} + +pub(super) fn safe_transient_resource_id(resource_id: &TransientResourceId) -> String { + // Truncate at the first char boundary at or before byte 8 to avoid allocating + // a temporary `String` of 8 chars (and a second UTF-8 walk via `len()`). + let full = resource_id.0.as_str(); + let truncate_at = full.char_indices().nth(8).map_or(full.len(), |(byte_idx, _)| byte_idx); + if truncate_at == full.len() { + return full.to_owned(); + } + let mut out = String::with_capacity(truncate_at + 3); + out.push_str(&full[..truncate_at]); + out.push_str("..."); + out +} + +pub(super) async fn validate_hls_proxy_access_request( + app_state: &Arc, + fingerprint: &Fingerprint, + proxy_session_id: &ProxySessionId, + hls_access_lease_id: &str, + now_ms: u64, + timing: HlsAccessLeaseTiming, + request_kind: &'static str, +) -> Result { + let context = validate_hls_proxy_access_context( + app_state, + fingerprint, + proxy_session_id, + hls_access_lease_id, + now_ms, + HlsAccessAdmissionMode::ResourceAccess, + ) + .await?; + let startup_admission_pending = app_state + .hls_proxy + .access_lease_response_snapshot(&context.lease_id, proxy_session_id, now_ms) + .await + .is_some_and(|lease| { + lease.state == HlsAccessLeaseState::Pending + && lease.startup_admission == HlsLeaseStartupAdmissionState::Pending + }); + if startup_admission_pending { + return Err(HlsAccessLeaseValidationError::AvailabilityPending); + } + match app_state.hls_proxy.activate_access_lease(&context.lease_id, proxy_session_id, now_ms, timing).await { + HlsAccessLeaseActivation::Activated { .. } => { + debug!( + "HLS access lease accepted: lease={} proxy_session={} user_session={} request={request_kind}", + safe_hls_access_lease_id(&context.lease_id), + safe_proxy_session_id(proxy_session_id), + safe_user_session_token(&context.user_session_token) + ); + Ok(context) + } + HlsAccessLeaseActivation::Denied => { + warn!( + "HLS access lease rejected: lease={} proxy_session={} user_session={} request={request_kind} reason=denied", + safe_hls_access_lease_id(&context.lease_id), + safe_proxy_session_id(proxy_session_id), + safe_user_session_token(&context.user_session_token) + ); + let (runtime_tail, reason) = app_state + .hls_proxy + .access_lease_response_snapshot(&context.lease_id, proxy_session_id, now_ms) + .await + .map_or((None, None), |lease| { + (lease.runtime_policy_revocation_outcome(), lease.runtime_policy_denial_reason()) + }); + Err(HlsAccessLeaseValidationError::AdmissionDenied { runtime_tail, reason }) + } + HlsAccessLeaseActivation::Expired + | HlsAccessLeaseActivation::UnknownLease + | HlsAccessLeaseActivation::SessionMismatch => { + warn!( + "HLS access lease rejected: lease={} proxy_session={} user_session={} request={request_kind} reason=expired", + safe_hls_access_lease_id(&context.lease_id), + safe_proxy_session_id(proxy_session_id), + safe_user_session_token(&context.user_session_token) + ); + Err(HlsAccessLeaseValidationError::Expired) + } + } +} + +pub(super) async fn ensure_hls_cache_stream_registered( + app_state: &Arc, + fingerprint: &Fingerprint, + req_headers: &HeaderMap, + access: &HlsAccessContext, + session: &HlsSessionHandle, +) -> Option { + let (proxy_session_id, origin_source, origin_account_binding) = { + let session = session.read().await; + if session.is_gc_marked_for_removal() { + return None; + } + (session.proxy_session_id.clone(), session.origin_source.clone(), session.origin_account_binding.clone()) + }; + let user = app_state.app_config.get_user_credentials(&access.username)?; + let user_session = + app_state.active_users.get_and_update_user_session(&access.username, &access.user_session_token).await?; + let connection_kind = user_session.connection_kind?; + let priority = connection_priority_for_kind(&user, connection_kind); + let mut stream_channel = build_hls_cache_stream_channel(app_state, access, &origin_source, &proxy_session_id).await; + let provider = hls_cache_stats_provider(&origin_source, origin_account_binding.as_ref(), &user_session); + let user_agent = req_headers + .get(header::USER_AGENT) + .map_or_else(|| Cow::Borrowed(""), |value| String::from_utf8_lossy(value.as_bytes())); + + stream_channel.url = Arc::from(hls_cache_stream_stats_url(&proxy_session_id)); + // Panel Streams/History read this item_type. Shared HLS transport is still HLS, but + // archive/catchup leases must never be published as Live/LiveHls. + let panel_archive_reference = origin_source + .archive_reference + .or(access.epg_reference_ts) + .or_else(|| access.archive_origin_url.as_deref().and_then(m3u_archive_epg_reference_ts)) + .or_else(|| m3u_catchup_epg_reference_from_session_token(&access.user_session_token)) + .or(stream_channel.epg_reference_ts); + let is_archive_playback = panel_archive_reference.is_some() + || access.archive_origin_url.is_some() + || origin_source.archive_reference.is_some() + || is_m3u_catchup_session_token(&access.user_session_token) + || stream_channel.item_type == PlaylistItemType::Catchup; + if is_archive_playback { + stream_channel.item_type = PlaylistItemType::Catchup; + stream_channel.cluster = XtreamCluster::Video; + stream_channel.epg_reference_ts = panel_archive_reference; + } else { + stream_channel.item_type = PlaylistItemType::LiveHls; + stream_channel.cluster = PlaylistItemType::LiveHls.cluster(); + } + let shared_stream_id = hls_cache_shared_stream_id(&proxy_session_id); + stream_channel.shared = true; + stream_channel.shared_stream_id = Some(shared_stream_id); + stream_channel.shared_joined_existing = Some( + hls_cache_shared_joined_existing(app_state, shared_stream_id, &access.username, &access.user_session_token) + .await, + ); + let qos_config = HlsQosRuntimeConfig::from_app_config(&app_state.app_config); + let qos_registration = app_state + .hls_proxy + .qos() + .ensure_access_lease( + &access.lease_id, + &proxy_session_id, + current_time_millis(), + hls_qos_meter_init(app_state, qos_config), + ) + .await; + if let Some(meter) = qos_registration.register_meter.as_ref() { + app_state.event_manager.register_meter(Arc::clone(meter)).await; + } + let history_mode = if qos_registration.emit_connect_record { + ConnectionHistoryMode::EmitConnect + } else { + ConnectionHistoryMode::RefreshOnly + }; + + app_state + .connection_manager + .update_connection_with_history_mode( + crate::api::model::ConnectionParams { + meter_uid: qos_registration.meter_uid, + username: &access.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind, + priority, + soft_priority: user.soft_priority, + fingerprint, + provider, + stream_channel: &stream_channel, + user_agent, + session_token: Some(&access.user_session_token), + }, + history_mode, + ) + .await +} + +pub(super) fn hls_cache_stats_provider( + origin_source: &HlsOriginSource, + origin_account_binding: Option<&HlsOriginAccountBinding>, + user_session: &UserSession, +) -> Arc { + origin_account_binding.filter(|binding| binding.is_active()).map_or_else( + || { + if user_session.provider.is_empty() { + Arc::clone(&origin_source.input_name) + } else { + Arc::clone(&user_session.provider) + } + }, + |binding| Arc::clone(&binding.account_name), + ) +} + +pub(super) async fn build_hls_cache_stream_channel( + app_state: &Arc, + access: &HlsAccessContext, + origin_source: &HlsOriginSource, + proxy_session_id: &ProxySessionId, +) -> StreamChannel { + let mut channel = if let Some((_, target)) = app_state.app_config.get_target_for_username(&access.username) { + if let Some(mut channel) = get_stream_channel(app_state, &target, access.virtual_id).await { + channel.url = Arc::from(hls_cache_stream_stats_url(proxy_session_id)); + channel + } else { + fallback_hls_cache_stream_channel(target.id, access.virtual_id, origin_source, proxy_session_id) + } + } else { + fallback_hls_cache_stream_channel(0, access.virtual_id, origin_source, proxy_session_id) + }; + + let archive_reference = access + .epg_reference_ts + .or_else(|| access.archive_origin_url.as_deref().and_then(m3u_archive_epg_reference_ts)) + .or_else(|| m3u_catchup_epg_reference_from_session_token(&access.user_session_token)); + + if archive_reference.is_some() + || access.archive_origin_url.is_some() + || is_m3u_catchup_session_token(&access.user_session_token) + { + channel.item_type = PlaylistItemType::Catchup; + channel.cluster = XtreamCluster::Video; + channel.epg_reference_ts = archive_reference; + } else { + channel.item_type = PlaylistItemType::LiveHls; + channel.cluster = PlaylistItemType::LiveHls.cluster(); + channel.epg_reference_ts = None; + } + channel +} + +pub(super) fn fallback_hls_cache_stream_channel( + target_id: u16, + virtual_id: u32, + origin_source: &HlsOriginSource, + proxy_session_id: &ProxySessionId, +) -> StreamChannel { + let unknown = "Unknown".intern(); + StreamChannel { + target_id, + virtual_id, + provider_id: 0, + input_name: Arc::clone(&origin_source.input_name), + item_type: PlaylistItemType::LiveHls, + cluster: XtreamCluster::Live, + group: unknown.clone(), + title: unknown, + url: Arc::from(hls_cache_stream_stats_url(proxy_session_id)), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + } +} + +pub(super) fn hls_cache_stream_stats_url(proxy_session_id: &ProxySessionId) -> String { + format!("/hls/shared/live/{}/manifest.m3u8", proxy_session_id.0) +} + +pub(super) fn hls_cache_shared_stream_id(proxy_session_id: &ProxySessionId) -> u64 { + let digest = Sha256::digest(proxy_session_id.0.as_bytes()); + digest.iter().take(8).fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)) +} + +pub(super) async fn hls_cache_shared_joined_existing( + app_state: &Arc, + shared_stream_id: u64, + username: &str, + session_token: &str, +) -> bool { + let streams = app_state.active_users.active_streams().await; + if let Some(existing) = streams.iter().find(|stream| { + stream.username == username + && stream.session_token.as_deref() == Some(session_token) + && stream.channel.shared + && stream.channel.shared_stream_id == Some(shared_stream_id) + }) { + return existing.channel.shared_joined_existing.unwrap_or(false); + } + + streams.iter().any(|stream| { + stream.channel.shared + && stream.channel.shared_stream_id == Some(shared_stream_id) + && (stream.username != username || stream.session_token.as_deref() != Some(session_token)) + }) +} + +pub(super) async fn validate_hls_proxy_access_context( + app_state: &Arc, + fingerprint: &Fingerprint, + proxy_session_id: &ProxySessionId, + hls_access_lease_id: &str, + now_ms: u64, + admission_mode: HlsAccessAdmissionMode, +) -> Result { + validate_hls_access_lease( + &app_state.hls_ctx(), + fingerprint, + proxy_session_id, + &HlsAccessLeaseId(hls_access_lease_id.to_string()), + now_ms, + admission_mode, + ) + .await +} + +pub(super) async fn hls_custom_video_manifest_response_for_username( + app_state: &Arc, + username: &str, + video_type: CustomVideoStreamType, + fallback_status: StatusCode, +) -> axum::response::Response { + if let Some(user) = app_state.app_config.get_user_credentials(username) { + return hls_custom_video_manifest_response(app_state, &user, video_type, fallback_status).await; + } + fallback_status.into_response() +} + +pub(super) async fn hls_custom_video_manifest_response_for_lease( + app_state: &Arc, + lease: &HlsAccessLease, + video_type: CustomVideoStreamType, + fallback_status: StatusCode, +) -> axum::response::Response { + let Some(user) = app_state.app_config.get_user_credentials(&lease.username) else { + return fallback_status.into_response(); + }; + hls_custom_video_manifest_response_for_access_lease(app_state, &user, video_type, fallback_status, lease).await +} + +pub(super) async fn hls_runtime_custom_tail_response( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + reason: HlsRuntimeCustomTailReason, + fallback_status: StatusCode, +) -> axum::response::Response { + let outcome = commit_hls_runtime_custom_tail( + app_state.hls_ctx(), + HlsRuntimeCustomTailRequest { + session: Arc::clone(session), + proxy_session_id: proxy_session_id.clone(), + lease_id: access_lease_id.clone(), + reason, + now_ms: current_time_millis(), + }, + ) + .await; + if matches!(outcome, HlsRuntimeCustomTailOutcome::Committed | HlsRuntimeCustomTailOutcome::AlreadyCommitted) { + let now_ms = current_time_millis(); + if let Some(lease) = + app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await + { + if let Some(response) = hls_terminal_playback_response(&lease, proxy_session_id, access_lease_id) { + return response; + } + } + } + if outcome == HlsRuntimeCustomTailOutcome::PendingOwnerRegistered { + return hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS); + } + fallback_status.into_response() +} + +pub(super) async fn hls_runtime_or_standalone_custom_tail_response( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + reason: HlsRuntimeCustomTailReason, + fallback_status: StatusCode, +) -> axum::response::Response { + let now_ms = current_time_millis(); + let Some(lease) = + app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, proxy_session_id, now_ms).await + else { + return fallback_status.into_response(); + }; + match &lease.playback_mode { + HlsLeasePlaybackMode::TerminalTail(_) | HlsLeasePlaybackMode::TerminalUnavailable { .. } => { + if let Some(response) = hls_terminal_playback_response(&lease, proxy_session_id, access_lease_id) { + return response; + } + } + HlsLeasePlaybackMode::Live + if lease.last_manifest_snapshot.is_some() + && matches!(lease.state, HlsAccessLeaseState::Activated | HlsAccessLeaseState::PolicyRevoking) => + { + return hls_runtime_custom_tail_response( + app_state, + session, + proxy_session_id, + access_lease_id, + reason, + fallback_status, + ) + .await; + } + HlsLeasePlaybackMode::Ended if !reason.permits_unpublished_lease_standalone_tail() => { + return fallback_status.into_response(); + } + HlsLeasePlaybackMode::Live | HlsLeasePlaybackMode::Ended => {} + } + hls_custom_video_manifest_response_for_lease(app_state, &lease, reason.video_type(), fallback_status).await +} + +pub(super) async fn hls_manifest_channel_unavailable_response_for_username( + app_state: &Arc, + username: &str, +) -> axum::response::Response { + hls_custom_video_manifest_response_for_username( + app_state, + username, + CustomVideoStreamType::ChannelUnavailable, + StatusCode::NOT_FOUND, + ) + .await +} + +/// Resolves the final canonical-manifest fallback after refresh and cached-live +/// publication have both produced no response. +pub(super) async fn hls_unpublished_lease_channel_unavailable_response( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, +) -> axum::response::Response { + let reason = HlsRuntimeCustomTailReason::ChannelUnavailable; + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(access_lease_id, proxy_session_id, current_time_millis()) + .await; + if let Some(lease) = lease.filter(|lease| lease.permits_unpublished_standalone_tail(reason)) { + return hls_custom_video_manifest_response_for_lease( + app_state, + &lease, + CustomVideoStreamType::ChannelUnavailable, + StatusCode::NOT_FOUND, + ) + .await; + } + StatusCode::SERVICE_UNAVAILABLE.into_response() +} + +pub(super) async fn hls_manifest_access_denial_runtime_response( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_snapshot: Option<&HlsAccessLease>, + reason: HlsRuntimeCustomTailReason, + fallback_status: StatusCode, +) -> axum::response::Response { + let Some(lease) = lease_snapshot else { + return fallback_status.into_response(); + }; + let Some(session) = app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await else { + return fallback_status.into_response(); + }; + hls_runtime_or_standalone_custom_tail_response( + app_state, + &session, + proxy_session_id, + &lease.lease_id, + reason, + fallback_status, + ) + .await +} + +pub(super) fn hls_resource_channel_unavailable_response( + _app_state: &Arc, + _access_context: &HlsAccessContext, +) -> axum::response::Response { + StatusCode::NOT_FOUND.into_response() +} + +pub(super) fn hls_origin_runtime_resource_failure_response( + _app_state: &Arc, + _access_context: &HlsAccessContext, + err: HlsOriginRuntimeAcquireError, +) -> axum::response::Response { + match err { + HlsOriginRuntimeAcquireError::NoAccountAvailable { .. } => StatusCode::SERVICE_UNAVAILABLE.into_response(), + HlsOriginRuntimeAcquireError::Fatal(status) => hls_canonical_status_response(status), + } +} + +pub(super) fn hls_resource_serve_outcome_response( + app_state: &Arc, + access_context: &HlsAccessContext, + outcome: HlsResourceServeOutcome, +) -> axum::response::Response { + match outcome { + HlsResourceServeOutcome::Ready(response) => response, + HlsResourceServeOutcome::Failure(HlsResourceServeFailure::TemporaryUnavailable { retry_after_ms }) => { + hls_temporary_resource_unavailable_response(retry_after_ms) + } + HlsResourceServeOutcome::Failure( + HlsResourceServeFailure::Missing + | HlsResourceServeFailure::Expired + | HlsResourceServeFailure::PermanentFailed { .. }, + ) => hls_resource_channel_unavailable_response(app_state, access_context), + } +} + +pub(super) async fn hls_manifest_access_lease_validation_response( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_snapshot: Option<&HlsAccessLease>, + err: HlsAccessLeaseValidationError, +) -> axum::response::Response { + match err { + HlsAccessLeaseValidationError::AdmissionDenied { reason, .. } => { + hls_manifest_access_denial_runtime_response( + app_state, + proxy_session_id, + lease_snapshot, + reason.unwrap_or(HlsRuntimeCustomTailReason::UserConnectionsExhausted), + StatusCode::FORBIDDEN, + ) + .await + } + HlsAccessLeaseValidationError::AvailabilityPending => { + hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) + } + HlsAccessLeaseValidationError::UserSessionMissing { .. } => { + hls_manifest_access_denial_runtime_response( + app_state, + proxy_session_id, + lease_snapshot, + HlsRuntimeCustomTailReason::SessionOrLeaseExpired, + StatusCode::NOT_FOUND, + ) + .await + } + HlsAccessLeaseValidationError::UserAccountExpired { .. } => { + hls_manifest_access_denial_runtime_response( + app_state, + proxy_session_id, + lease_snapshot, + HlsRuntimeCustomTailReason::UserAccountExpired, + StatusCode::FORBIDDEN, + ) + .await + } + HlsAccessLeaseValidationError::Expired => StatusCode::NOT_FOUND.into_response(), + } +} + +pub(super) fn hls_resource_access_lease_validation_response( + err: &HlsAccessLeaseValidationError, +) -> axum::response::Response { + match err { + HlsAccessLeaseValidationError::AdmissionDenied { .. } + | HlsAccessLeaseValidationError::UserAccountExpired { .. } => StatusCode::FORBIDDEN.into_response(), + HlsAccessLeaseValidationError::AvailabilityPending => { + hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) + } + HlsAccessLeaseValidationError::UserSessionMissing { .. } | HlsAccessLeaseValidationError::Expired => { + StatusCode::NOT_FOUND.into_response() + } + } +} + +pub(super) async fn hls_manifest_access_context_and_state( + app_state: &Arc, + fingerprint: &Fingerprint, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + access_lease_snapshot: Option<&HlsAccessLease>, + now_ms: u64, +) -> Result<(HlsAccessContext, HlsAccessLeaseState), Box> { + app_state.hls_proxy.startup_observability().record_media_manifest_request(access_lease_id, now_ms); + let access_context = match validate_hls_proxy_access_context( + app_state, + fingerprint, + proxy_session_id, + &access_lease_id.0, + now_ms, + HlsAccessAdmissionMode::ManifestPrepare, + ) + .await + { + Ok(context) => context, + Err(err) => { + warn!( + "HLS access lease rejected: lease={} proxy_session={} user_session=none reason={err:?}", + safe_hls_access_lease_id(access_lease_id), + safe_proxy_session_id(proxy_session_id) + ); + return Err(Box::new( + hls_manifest_access_lease_validation_response(app_state, proxy_session_id, access_lease_snapshot, err) + .await, + )); + } + }; + if access_lease_snapshot.is_none() + && app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await.is_none() + && app_state.hls_proxy.expired_session_marker(proxy_session_id, now_ms).await.is_some() + { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + } + debug!( + "HLS access lease accepted: lease={} proxy_session={} user_session={} request=manifest", + safe_hls_access_lease_id(&access_context.lease_id), + safe_proxy_session_id(proxy_session_id), + safe_user_session_token(&access_context.user_session_token) + ); + + let access_lease_state = match app_state + .hls_proxy + .touch_manifest_access_lease( + &access_context.lease_id, + proxy_session_id, + now_ms, + None, + Some(HlsAccessLeasePendingDeadline::Bootstrap { + deadline_ms: now_ms.saturating_add(hls_pending_bootstrap_window_ms(app_state)), + }), + hls_access_lease_ttl_ms(app_state), + ) + .await + { + HlsAccessLeaseTouch::Touched { lease } => lease.state, + HlsAccessLeaseTouch::Denied => { + return Err(Box::new( + hls_manifest_access_denial_runtime_response( + app_state, + proxy_session_id, + access_lease_snapshot, + HlsRuntimeCustomTailReason::UserConnectionsExhausted, + StatusCode::FORBIDDEN, + ) + .await, + )); + } + HlsAccessLeaseTouch::Expired | HlsAccessLeaseTouch::UnknownLease | HlsAccessLeaseTouch::SessionMismatch => { + return Err(Box::new(StatusCode::NOT_FOUND.into_response())); + } + }; + + Ok((access_context, access_lease_state)) +} + +pub(super) async fn hls_transient_object_unavailable_response( + app_state: &Arc, + session: &HlsSessionHandle, + resource_file: &TransientResourceFile, + now_ms: u64, + access_context: &HlsAccessContext, +) -> axum::response::Response { + let state = { + let session = session.read().await; + let key = TransientPassthroughState::transient_object_key( + &session.proxy_session_id, + &resource_file.resource_id, + resource_file.extension.clone(), + ); + session.transient.object_unavailable_state(&key, now_ms) + }; + match state { + TransientObjectUnavailableState::Fetching => { + hls_temporary_resource_unavailable_response(HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS) + } + TransientObjectUnavailableState::FailedRetryable { retry_after_ms } => { + hls_temporary_resource_unavailable_response(retry_after_ms) + } + TransientObjectUnavailableState::FailedPermanent | TransientObjectUnavailableState::Missing => { + hls_resource_channel_unavailable_response(app_state, access_context) + } + } +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn fetch_and_cache_transient_origin_response( + context: HlsTransientEndpointCacheFetchContext<'_>, +) -> axum::response::Response { + let policy = context.app_state.hls_proxy.segment_fetch_policy(); + let mut fetch_finalizer = HlsTransientObjectFetchFinalizer::new( + Arc::clone(context.session), + Arc::clone(context.app_state.hls_proxy.segment_cache()), + context.fetch_token.clone(), + HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, + ); + let clients = HlsOriginResourceClients { + client: context.app_state.http_client.load().as_ref().clone(), + no_redirect_client: context.app_state.http_client_no_redirect.load().as_ref().clone(), + use_manual_redirects: context.app_state.should_use_manual_redirects(), + }; + let log_identity = { + let session = context.session.read().await; + HlsLogIdentity::from_session(&session) + }; + let fetch_request = HlsTransientOriginFetchRequest { + resolved_origin_uri: context.resource.resolved_origin_uri.clone(), + origin_headers: context.origin_headers.clone(), + origin_provider_session_headers: context.origin_provider_session_headers.clone(), + range_header: None, + resource_file: context.resource_file.clone(), + resource_kind: context.resource.kind, + clients, + policy: policy.clone(), + log_identity, + }; + let cache_fetch_request = HlsTransientOriginCacheFetchRequest { + fetch: fetch_request, + commit: HlsTransientCacheCommitContext { + segment_cache: Arc::clone(context.app_state.hls_proxy.segment_cache()), + segment_repair: Arc::clone(context.app_state.hls_proxy.segment_repair()), + session: Arc::clone(context.session), + access_lease_id: context.access_context.lease_id.clone(), + resource: context.resource.clone(), + resource_file: context.resource_file.clone(), + fetch_token: context.fetch_token.clone(), + cache_duration_ms: context.cache_duration_ms, + }, + }; + let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); + let prepare_attempt = hls_transient_origin_prepare_closure( + context.app_state, + context.session, + context.access_context, + context.fingerprint, + context.headers, + &runtime_prepare_error, + ); + let final_failure = + match fetch_and_commit_hls_transient_origin_response_with_attempt_prepare(cache_fetch_request, prepare_attempt) + .await + { + Ok(()) => { + let ready_at_ms = current_time_millis(); + let response_context = hls_cache_response_context( + context.app_state, + context.session, + context.access_context, + context.lease_identity, + ready_at_ms, + ) + .await; + let response = serve_hls_transient_object_cache_response( + Arc::clone(context.app_state.hls_proxy.segment_cache()), + Arc::clone(context.session), + context.resource_file.clone(), + context.range_header.clone(), + &response_context, + ) + .await; + if is_hls_media_activity_status(response.status()) { + register_hls_cache_stream_for_successful_media_response( + context.app_state, + context.fingerprint, + context.headers, + context.access_context, + context.session, + &response_context, + ) + .await; + } + record_successful_transient_segment_fetch(context.session, context.resource).await; + fetch_finalizer.complete(); + return response; + } + Err(err) => { + if matches!(err, HlsOriginResourceFetchError::ProviderUnavailable(_)) { + let runtime_prepare_error = *runtime_prepare_error.lock().await; + if let Some(runtime_err) = runtime_prepare_error { + context.session.write().await.fail_transient_object_retryable_if_current( + &context.fetch_token, + current_time_millis(), + HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, + ); + return hls_origin_runtime_resource_failure_response( + context.app_state, + context.access_context, + runtime_err, + ); + } + } + hls_transient_object_fetch_failure(&err) + } + }; + + let failed_at_ms = current_time_millis(); + match final_failure { + HlsTransientObjectFetchFailure::Retryable => { + if record_temporary_transient_segment_fetch_failure( + context.session, + context.resource, + &policy, + failed_at_ms, + ) + .await + { + context.session.write().await.fail_transient_object_permanent_if_current( + &context.fetch_token, + failed_at_ms, + None, + ); + } else { + context.session.write().await.fail_transient_object_retryable_if_current( + &context.fetch_token, + failed_at_ms, + HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS, + ); + } + } + HlsTransientObjectFetchFailure::Permanent { status } => { + context.session.write().await.fail_transient_object_permanent_if_current( + &context.fetch_token, + failed_at_ms, + status, + ); + } + } + hls_transient_object_unavailable_response( + context.app_state, + context.session, + &context.resource_file, + failed_at_ms, + context.access_context, + ) + .await +} + +use tuliprox_core::utils::current_time_millis; + +pub(super) async fn release_prepared_hls_manifest_session( + app_state: &Arc, + username: &str, + session_token: &str, + addr: &std::net::SocketAddr, +) { + let _transition_guard = app_state.active_users.acquire_playback_transition(username, session_token).await; + app_state.active_users.release_unbound_session_reservation(username, session_token, None, false).await; + app_state.active_users.clear_unbound_session_addr(username, session_token, addr).await; +} + +pub(super) async fn terminate_failed_hls_manifest_session( + app_state: &Arc, + username: &str, + session_token: &str, +) { + let _transition_guard = app_state.active_users.acquire_playback_transition(username, session_token).await; + app_state.active_users.terminate_session(username, session_token).await; + app_state.active_provider.clear_provider_reservation(session_token).await; +} + +pub(super) fn normalize_xtream_live_hls_url(hls_url: &str, input: &ConfigInput) -> String { + if !input.input_type.is_xtream() || !input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix) { + return hls_url.to_string(); + } + + let (Some(username), Some(password)) = (input.username.as_deref(), input.password.as_deref()) else { + return hls_url.to_string(); + }; + + let Ok(mut parsed) = Url::parse(hls_url) else { + return hls_url.to_string(); + }; + let Some(segments) = parsed.path_segments() else { + return hls_url.to_string(); + }; + + let parts: Vec<&str> = segments.collect(); + if parts.len() >= 3 && parts[0] == username && parts[1] == password { + parsed.set_path(&format!("/live/{}", parts.join("/"))); + return parsed.to_string(); + } + + hls_url.to_string() +} + +pub(super) fn ensure_hls_manifest_extension(url: &str) -> String { + let with_extension = replace_url_extension(url, HLS_EXT); + let (base_url, suffix) = match with_extension.find(['?', '#'].as_ref()) { + Some(pos) => (&with_extension[..pos], &with_extension[pos..]), + None => (with_extension.as_str(), ""), + }; + let Some(path_without_ext) = base_url.strip_suffix(HLS_EXT) else { + return with_extension; + }; + format!("{}{}{}", path_without_ext.trim_end_matches('.'), HLS_EXT, suffix) +} + +pub(super) fn build_hls_manifest_request_headers( + input_headers: &HashMap, + req_headers: &HeaderMap, + disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, + default_user_agent: Option<&str>, + upstream_user_agent: Option<&str>, +) -> HeaderMap { + let input_headers = input_headers + .iter() + .filter(|(key, _)| !should_remove_hls_origin_header(key, disabled_headers)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let disabled_headers_for_filter = disabled_headers.cloned(); + let filter_header: HeaderFilter = Some(Box::new(move |name: &str| { + !name.eq_ignore_ascii_case("range") + && !should_remove_hls_origin_header(name, disabled_headers_for_filter.as_ref()) + })); + let forwarded = get_headers_from_request(req_headers, &filter_header); + let mut headers = + request::get_request_headers(Some(&input_headers), Some(&forwarded), disabled_headers, default_user_agent); + request::overlay_upstream_user_agent(&mut headers, upstream_user_agent, disabled_headers); + scrub_hls_origin_headers(&mut headers, disabled_headers); + force_identity_without_range(&mut headers); + headers +} + +pub(super) async fn download_legacy_hls_manifest( + app_state: &Arc, + input: &InputSource, + headers: &HeaderMap, +) -> Result<(String, String, HeaderMap), std::io::Error> { + let deadline = Duration::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms().max(1)); + let fetch_options = request::RequestFetchOptions::with_attempt_idle_timeout(deadline) + .with_content_coding(OutboundContentCodingPolicy::Identity); + let body_options = request::TextContentBodyOptions::hls_manifest(MAX_HLS_MANIFEST_BYTES, deadline); + let options = request::TextContentFetchOptions::new(fetch_options, body_options); + + if app_state.should_use_manual_redirects() { + request::download_text_content_with_manual_redirects_and_headers_and_options( + &app_state.app_config, + &app_state.http_client_no_redirect.load(), + input, + Some(headers), + false, + MAX_MANUAL_REDIRECTS, + options, + ) + .await + } else { + request::download_text_content_with_headers_and_options( + &app_state.app_config, + &app_state.http_client.load(), + input, + Some(headers), + false, + options, + ) + .await + } +} + +pub(super) struct HlsCacheManifestOrigin<'a> { + pub(super) raw_request_url: &'a str, + pub(super) session_entry_url: HlsOriginEntryUrl, + pub(super) input: &'a ConfigInput, + pub(super) origin_source: HlsOriginSource, +} + +pub(super) struct HlsCacheOriginResolution { + pub(super) hls_url: String, + pub(super) session_entry_url: HlsOriginEntryUrl, +} + +pub(in crate::api) fn build_virtual_hls_entry_path( + target: &ConfigTarget, + input: &ConfigInput, + user: &ProxyUserCredentials, + virtual_id: u32, +) -> String { + if input.input_type.is_m3u() && !target.has_output(TargetType::Xtream) { + format!("/{}/live/{}/{}/{}{HLS_EXT}", storage_const::M3U_STREAM_PATH, user.username, user.password, virtual_id) + } else { + format!("/live/{}/{}/{}{HLS_EXT}", user.username, user.password, virtual_id) + } +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(in crate::api) async fn handle_hls_stream_request( + fingerprint: &Fingerprint, + app_state: &Arc, + user: &ProxyUserCredentials, + target: &ConfigTarget, + user_session: Option<&UserSession>, + session_token_hint: Option<&str>, + hls_url: &str, + archive_reference: Option, + stream_context: HlsEntryStreamContext, + input: &ConfigInput, + req_headers: &HeaderMap, + connection_permission: UserConnectionPermission, + connection_kind: Option, + original_hls_entry_path: &str, +) -> impl IntoResponse + Send { + let virtual_id = stream_context.virtual_id(); + if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + + let stream_ref = stream_context.stream_ref().to_string(); + let normalized_hls_url = normalize_xtream_live_hls_url(hls_url, input); + if normalized_hls_url != hls_url { + debug_if_enabled!( + "Normalized xtream hls url from {} to {}", + sanitize_sensitive_info(hls_url), + sanitize_sensitive_info(&normalized_hls_url) + ); + } + let url = ensure_hls_manifest_extension(&normalized_hls_url); + // Recover archive context when callers (esp. Xtream timeshift) pass None but the + // resolved provider URL / catchup session still carries Flussonic archive markers. + let archive_reference = archive_reference.or_else(|| m3u_archive_epg_reference_ts(&url)).or_else(|| { + user_session + .map(|session| session.token.as_str()) + .or(session_token_hint) + .and_then(m3u_catchup_epg_reference_from_session_token) + }); + let hls_cache_origin = build_hls_origin_resolution(input, &url); + let hls_origin_source = hls_cache_origin.as_ref().map(|_| { + build_hls_origin_source_for_playback(input, stream_ref.clone(), archive_reference, Some(url.as_str())) + }); + let server_info = app_state.app_config.get_user_server_info(user); + let Some(server_info) = server_info else { + return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + + let disabled_headers = app_state.get_disabled_headers(); + let default_user_agent = app_state.app_config.config.load().default_user_agent.clone(); + let headers = build_hls_manifest_request_headers( + &input.headers, + req_headers, + disabled_headers.as_ref(), + default_user_agent.as_deref(), + stream_context.identity().upstream_user_agent(), + ); + + if hls_cache_enabled_for_target(app_state, target) { + let Some(origin_source) = hls_origin_source.clone() else { + return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + return create_hls_cache_entry_master_playlist_response( + app_state, + fingerprint, + user, + origin_source, + virtual_id, + user_session, + stream_context.known_bitrate_bps(), + session_token_hint, + if archive_reference.is_some() { + url.as_str() + } else { + hls_cache_origin.as_ref().map_or(url.as_str(), |origin| origin.session_entry_url.as_str()) + }, + input, + connection_permission, + connection_kind, + server_info.path.as_deref(), + ) + .await; + } + + let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); + let (request_url, session_token, provider_handle, _selected_provider_config) = if let Some(session) = user_session { + let pinned_provider = if session.provider.is_empty() { &input.name } else { &session.provider }; + let provider_handle = if let Some(handle) = app_state + .active_provider + .acquire_exact_connection_with_grace_for_session( + pinned_provider, + &fingerprint.addr, + false, + connection_priority_for_kind( + user, + session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal), + ), + session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal), + Some(session.token.as_str()), + ) + .await + { + Some(handle) + } else { + debug_if_enabled!( + "HLS pinned provider {} unavailable for {}; aborting allocation to prevent mid-session migration", + sanitize_sensitive_info(pinned_provider), + sanitize_sensitive_info(&fingerprint.addr.to_string()) + ); + None + }; + + if provider_handle.is_none() { + return hls_panel_provisioning_or_status_response( + app_state, + user, + input, + virtual_id, + original_hls_entry_path, + server_info.path.as_deref(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + } + match provider_handle.as_ref().map(|handle| &handle.allocation) { + Some(ProviderAllocation::Exhausted) => (url, None, provider_handle, None), + Some(ProviderAllocation::Available(cfg) | ProviderAllocation::GracePeriod(cfg)) => { + let selected_provider_config = Arc::clone(cfg); + let Some(stream_url) = get_stream_alternative_url(&url, input, cfg) else { + app_state.connection_manager.release_provider_handle(provider_handle).await; + return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + let session_token = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token: &session.token, + virtual_id, + provider: &cfg.name, + stream_url: &stream_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: session.connection_kind.or(connection_kind), + socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), + }) + .await; + let hls_session_ttl_secs = get_hls_session_ttl_secs(app_state); + app_state + .active_provider + .refresh_provider_reservation(&cfg.name, &session_token, hls_session_ttl_secs) + .await; + (stream_url, Some(session_token), provider_handle, Some(selected_provider_config)) + } + None => (url, None, None, None), + } + } else { + // Append/shift catchup must keep an m3u-catchup session token even when shared HLS + // cache is off; otherwise rewritten segments register as LiveHls in the panel. + let user_session_token = hls_entry_user_session_token( + fingerprint, + &user.username, + virtual_id, + session_token_hint, + archive_reference, + ); + let hls_session_owner = if hls_cache_enabled_for_target(app_state, target) { + let session_key = HlsSessionKey::new(input.id, stream_context.stream_ref()); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + Some(build_hls_origin_session_owner(&proxy_session_id)) + } else { + None + }; + let session_owner = hls_session_owner.as_deref().unwrap_or(user_session_token.as_str()); + let hls_session_ttl_secs = get_hls_session_ttl_secs(app_state); + let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( + app_state, + fingerprint, + user, + input, + virtual_id, + &url, + &user_session_token, + session_owner, + hls_session_ttl_secs, + connection_permission, + fallback_connection_kind, + true, + ) + .await + else { + return hls_panel_provisioning_or_status_response( + app_state, + user, + input, + virtual_id, + original_hls_entry_path, + server_info.path.as_deref(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + }; + debug_if_enabled!( + "API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}", + sanitize_sensitive_info(&user.username), + reservation.selected_provider_config.as_ref().map_or("", |provider| provider.name.as_ref()), + sanitize_sensitive_info(&reservation.request_url) + ); + ( + reservation.request_url, + Some(reservation.session_token), + reservation.provider_handle, + reservation.selected_provider_config, + ) + }; + + // Playlist requests only need the chosen provider account to derive the URL and pin the session. + // Holding the provider slot until the first segment request causes stale active connections and + // breaks forced same-account reuse on the next HLS/Catchup stream request. + app_state.connection_manager.release_provider_handle(provider_handle).await; + + let input_source = InputSource::from(input).with_url(request_url); + let download_result = download_legacy_hls_manifest(app_state, &input_source, &headers).await; + match download_result { + 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 { + secret: &encrypt_secret, + base_url: &base_url, + content: &content, + hls_url: response_url, + target_id: target.id, + virtual_id, + input_id: input.id, + user_token: session_token.as_deref(), + }; + 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() + } + Err(err) => { + error!("Failed to download m3u8: {}", request::text_response_error_log_label(&err)); + if let Some(session_token) = session_token.as_deref() { + terminate_failed_hls_manifest_session(app_state, &user.username, session_token).await; + } + + hls_custom_video_manifest_response( + app_state, + user, + CustomVideoStreamType::ChannelUnavailable, + StatusCode::NOT_FOUND, + ) + .await + } + } +} + +pub(super) async fn get_stream_channel( + app_state: &Arc, + target: &Arc, + virtual_id: u32, +) -> Option { + if target.has_output(TargetType::Xtream) { + if let Ok(pli) = + xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None).await + { + return Some(pli.to_stream_channel(target.id)); + } + } + let target_id = target.id; + m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) + .await + .ok() + .map(|pli| pli.to_stream_channel(target_id)) +} + +pub(super) fn hls_stream_context_or_unavailable( + item: &impl PlaylistEntry, + virtual_id: u32, +) -> Result { + HlsEntryStreamContext::from_playlist_item(item).ok_or_else(|| { + warn!("HLS input stream identity missing for virtual_id={virtual_id}; refresh target playlist"); + StatusCode::SERVICE_UNAVAILABLE + }) +} + +pub(in crate::api) async fn resolve_hls_virtual_source_for_target( + app_state: &Arc, + target: &Arc, + virtual_id: u32, +) -> Result { + let (input_name, stream_context) = if target.has_output(TargetType::Xtream) { + if let Ok(item) = + xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None).await + { + let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; + (Arc::clone(&item.input_name), stream_context) + } else { + let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; + (Arc::clone(&item.input_name), stream_context) + } + } else { + let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?; + (Arc::clone(&item.input_name), stream_context) + }; + let input = app_state.app_config.get_input_by_name(&input_name).ok_or(StatusCode::NOT_FOUND)?; + Ok(HlsResolvedVirtualSource { input, stream_context }) +} + +pub(super) async fn resolve_hls_origin_playlist_url( + app_state: &Arc, + target: &Arc, + input: &ConfigInput, + virtual_id: u32, + fallback_url: &str, +) -> Result { + if input.input_type.is_xtream() && target.has_output(TargetType::Xtream) { + let pli = xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + let hls_extension = format!(".{HLS_EXT}"); + let (query_path, _) = get_query_path("", Some(&hls_extension), &pli, app_state); + return get_xtream_player_api_stream_url(input, ApiStreamContext::Live, &query_path, &pli.url) + .map(|url| url.to_string()) + .ok_or(StatusCode::SERVICE_UNAVAILABLE); + } + + Ok(fallback_url.to_string()) +} + +pub(super) async fn resolve_stream_channel( + app_state: &Arc, + target: &Arc, + input: &Arc, + virtual_id: u32, + hls_url: &str, + archive_reference: Option, + session_token: Option<&str>, +) -> StreamChannel { + let unknown = "Unknown".intern(); + let mut channel = match get_stream_channel(app_state, target, virtual_id).await { + Some(mut channel) => { + channel.url = Arc::from(hls_url); + channel + } + None => StreamChannel { + target_id: target.id, + virtual_id, + provider_id: 0, + input_name: Arc::clone(&input.name), + item_type: PlaylistItemType::LiveHls, + cluster: XtreamCluster::Live, + group: unknown.clone(), + title: unknown, + url: Arc::from(hls_url), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + }, + }; + + let archive_reference = archive_reference.or_else(|| epg_reference_ts_from_date_tree_path(hls_url)); + // Append/shift catchup often loses utc/utcstart on rewritten segment URLs; the session + // token still identifies archive playback for Streams/History (Catchup, not Live/HLS). + let is_archive_playback = archive_reference.is_some() + || looks_like_archive_media_path(hls_url) + || session_token.is_some_and(is_m3u_catchup_session_token); + if is_archive_playback { + channel.item_type = PlaylistItemType::Catchup; + channel.cluster = XtreamCluster::Video; + channel.epg_reference_ts = archive_reference; + } else { + channel.item_type = PlaylistItemType::LiveHls; + channel.epg_reference_ts = None; + } + channel +} + +pub(super) fn hls_entry_user_session_token( + fingerprint: &Fingerprint, + username: &str, + virtual_id: u32, + session_token_hint: Option<&str>, + archive_reference: Option, +) -> String { + if let Some(hint) = session_token_hint.filter(|token| is_m3u_catchup_session_token(token)) { + return hint.to_string(); + } + if let Some(timestamp) = archive_reference { + return create_m3u_catchup_session_key(fingerprint, username, virtual_id, &format!("archive|{timestamp}|0")); + } + create_playback_session_fingerprint(fingerprint, username, virtual_id, PlaylistItemType::LiveHls, None) +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn hls_api_stream( + fingerprint: Fingerprint, + req_headers: HeaderMap, + axum::extract::RawQuery(raw_query): axum::extract::RawQuery, + axum::extract::Path(params): axum::extract::Path, + axum::extract::State(app_state): axum::extract::State>, +) -> impl IntoResponse + Send { + let api_proxy_user = create_api_proxy_user(&app_state); + let (user, target) = if params.username == api_proxy_user.username + && crate::auth::constant_time_eq(params.password.as_bytes(), api_proxy_user.password.as_bytes()) + { + let Some(target) = app_state.app_config.get_target_by_id(params.target_id) else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + }; + (Arc::new(api_proxy_user), target) + } else { + let Some((user, target)) = app_state.app_config.get_target_for_user(¶ms.username, ¶ms.password) else { + // Credential failure is an auth error, not a malformed request + return app_state.app_config.get_auth_error_status().into_response(); + }; + if target.id != params.target_id { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + (user, target) + }; + + // Nested path = relative origin segment that leaked past rewrite_hls (e.g. dvr-YYYY/...). + if params.token.contains('/') { + let Some((token, relative_path)) = params.token.split_once('/') else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let encrypt_secret = app_state.get_encrypt_secret(); + let Some(decoded_hls_token) = get_hls_session_token_and_url_from_token(&encrypt_secret, token) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let lookup_session_token = decoded_hls_token + .0 + .clone() + .unwrap_or_else(|| create_session_fingerprint(&fingerprint, &user.username, params.stream_id, false)); + let Some(input) = app_state.app_config.get_input_by_id(params.input_id) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let Some(session) = app_state + .active_users + .find_latest_session_for_target_stream( + &user.username, + target.id, + input.name.as_ref(), + params.stream_id, + lookup_session_token.as_str(), + ) + .await + else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !legacy_hls_route_allowed_with_cache( + hls_cache_enabled_for_target(&app_state, &target), + decoded_hls_token.0.as_deref(), + Some(session.token.as_str()), + ) { + return hls_custom_video_manifest_response_for_username( + &app_state, + &user.username, + CustomVideoStreamType::ChannelUnavailable, + StatusCode::NOT_FOUND, + ) + .await; + } + return hls_api_stream_leaked_relative( + fingerprint, + req_headers, + app_state, + user, + target, + input, + params.stream_id, + session, + decoded_hls_token.1, + relative_path.to_string(), + raw_query.as_deref(), + ) + .await; + } + + hls_api_stream_resolved( + fingerprint, + req_headers, + app_state, + user, + target, + params.input_id, + params.stream_id, + params.token, + ) + .await +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(super) async fn hls_api_stream_resolved( + fingerprint: Fingerprint, + req_headers: HeaderMap, + app_state: Arc, + user: Arc, + target: Arc, + input_id: u16, + stream_id: u32, + token: String, +) -> axum::response::Response { + // Network access check only - permission check is done later with full stream info + if let Err(e) = check_network_access_only(&user, &fingerprint, &app_state.app_config, &app_state.geoip) { + return e.into_player_response(app_state.app_config.get_auth_error_status()); + } + let target_name = &target.name; + let virtual_id = stream_id; + let input = try_option_bad_request!( + app_state.app_config.get_input_by_id(input_id), + true, + format!("Can't find input {} for target {target_name}, stream_id {virtual_id}, hls", input_id) + ); + + if user.permission_denied(&app_state.app_config) { + let stream_channel = resolve_stream_channel(&app_state, &target, &input, virtual_id, "", None, None).await; + return hls_admission_failure_manifest_response( + &app_state, + &fingerprint, + &user, + stream_channel, + input.name.clone(), + &req_headers, + ConnectFailureReason::UserAccountExpired, + ) + .await; + } + + debug_if_enabled!("ID chain for hls endpoint: request_stream_id={stream_id} -> virtual_id={virtual_id}"); + let encrypt_secret = app_state.get_encrypt_secret(); + let Some(decoded_hls_token) = get_hls_session_token_and_url_from_token(&encrypt_secret, &token) else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + }; + let lookup_session_token = decoded_hls_token + .0 + .clone() + .unwrap_or_else(|| create_session_fingerprint(&fingerprint, &user.username, virtual_id, false)); + let mut user_session = + app_state.active_users.get_and_update_user_session(&user.username, &lookup_session_token).await; + if !legacy_hls_route_allowed_with_cache( + hls_cache_enabled_for_target(&app_state, &target), + decoded_hls_token.0.as_deref(), + user_session.as_ref().map(|session| session.token.as_str()), + ) { + return hls_manifest_channel_unavailable_response_for_username(&app_state, &user.username).await; + } + + if let Some(session) = &mut user_session { + let decoded_archive_reference = + resolve_m3u_archive_reference(&decoded_hls_token.1, Some(lookup_session_token.as_str())); + if session.permission == UserConnectionPermission::Exhausted { + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &decoded_hls_token.1, + decoded_archive_reference, + Some(session.token.as_str()), + ) + .await; + return hls_admission_failure_manifest_response( + &app_state, + &fingerprint, + &user, + stream_channel, + session.provider.clone(), + &req_headers, + ConnectFailureReason::UserConnectionsExhausted, + ) + .await; + } + + if app_state.active_provider.is_over_limit(&session.provider).await { + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &decoded_hls_token.1, + decoded_archive_reference, + Some(session.token.as_str()), + ) + .await; + return hls_admission_failure_manifest_response( + &app_state, + &fingerprint, + &user, + stream_channel, + session.provider.clone(), + &req_headers, + ConnectFailureReason::ProviderConnectionsExhausted, + ) + .await; + } + + let hls_url = match decoded_hls_token { + (Some(session_token), hls_url) if session.token.eq(&session_token) => hls_url, + (None, hls_url) => hls_url, + _ => return axum::http::StatusCode::BAD_REQUEST.into_response(), + }; + let hls_url = hls_url.intern(); + // Recover utc/utcstart from the prior playlist URL before overwriting with a segment URL + // that usually drops append/shift query params. + let archive_reference = resolve_m3u_archive_reference(&hls_url, Some(session.token.as_str())) + .or_else(|| m3u_archive_epg_reference_ts(session.stream_url.as_ref())); + session.stream_url = hls_url.clone(); + if session.virtual_id == virtual_id { + app_state.connection_manager.touch_http_activity(&user.username, &session.token, &fingerprint.addr).await; + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &hls_url, + archive_reference, + Some(session.token.as_str()), + ) + .await; + if is_seekable_media_request(stream_channel.cluster, &req_headers, extract_extension_from_url(&hls_url)) { + // partial request means we are in reverse proxy mode, seek happened + return force_provider_stream_response( + &fingerprint, + &app_state, + session, + stream_channel, + crate::api::api_utils::ForceStreamRequestContext { + req_headers: &req_headers, + input: &input, + user: &user, + session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), + content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, + }, + None, + ) + .await + .into_response(); + } + } else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + + let (connection_admission, grace_mode, request_class) = + crate::api::api_utils::resolve_playback_request_admission( + &app_state.admission_ctx(), + &user, + &fingerprint, + Some(session), + &session.token, + true, + crate::api::api_utils::EvictionReentryGuard::Session(&session.token), + // HLS playlist requests (.m3u8) are explicit Prepare: they set up session metadata + // but do not consume an admission slot. Segment and other media requests use Activate. + is_hls_url(&hls_url), + false, + ) + .await; + let connection_permission = connection_admission.permission; + let connection_kind = connection_admission.kind.or(session.connection_kind); + session.permission = connection_permission; + if let Some(connection_kind) = connection_kind { + session.connection_kind = Some(connection_kind); + } + if connection_permission == UserConnectionPermission::Exhausted + || (connection_permission == UserConnectionPermission::GracePeriod && connection_kind.is_none()) + { + let provider = if session.provider.is_empty() { input.name.clone() } else { session.provider.clone() }; + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &session.stream_url, + archive_reference, + Some(session.token.as_str()), + ) + .await; + return hls_admission_failure_manifest_response( + &app_state, + &fingerprint, + &user, + stream_channel, + provider, + &req_headers, + ConnectFailureReason::UserConnectionsExhausted, + ) + .await; + } + let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); + + if is_hls_url(&session.stream_url) { + let source = match resolve_hls_virtual_source_for_target(&app_state, &target, virtual_id).await { + Ok(source) if source.input.id == input.id => source, + Ok(source) => { + warn!( + "HLS input context mismatch for virtual_id={virtual_id}: expected_input_id={}, resolved_input_id={}", + input.id, source.input.id + ); + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + Err(status) => return status.into_response(), + }; + let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id); + return handle_hls_stream_request( + &fingerprint, + &app_state, + &user, + &target, + Some(session), + None, + &session.stream_url, + archive_reference, + source.stream_context, + &input, + &req_headers, + connection_permission, + connection_kind, + &original_hls_entry_path, + ) + .await + .into_response(); + } + + if is_file_url(&session.stream_url) { + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &hls_url, + archive_reference, + Some(session.token.as_str()), + ) + .await; + return local_stream_response( + &fingerprint, + &app_state, + stream_channel, + &req_headers, + &input, + &target, + &user, + connection_permission, + fallback_connection_kind, + Some(&session.token), + Some(request_class), + false, + ) + .await + .into_response(); + } + + let stream_channel = resolve_stream_channel( + &app_state, + &target, + &input, + virtual_id, + &hls_url, + archive_reference, + Some(session.token.as_str()), + ) + .await; + force_provider_stream_response( + &fingerprint, + &app_state, + session, + stream_channel, + crate::api::api_utils::ForceStreamRequestContext { + req_headers: &req_headers, + input: &input, + user: &user, + session_reservation_ttl_secs: get_hls_session_ttl_secs(&app_state), + content_representation: crate::api::model::ProviderContentRepresentationMode::Identity, + }, + grace_mode, + ) + .await + .into_response() + } else { + axum::http::StatusCode::BAD_REQUEST.into_response() + } +} diff --git a/backend/app/src/api/endpoints/hls_api/session.rs b/backend/app/src/api/endpoints/hls_api/session.rs new file mode 100644 index 000000000..46717ecf5 --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/session.rs @@ -0,0 +1,1993 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +#[derive(Clone, Debug)] +pub(super) enum HlsOriginEntryUrl { + DirectHttp { url: String }, + ProviderFailover { url: String, provider: Arc }, +} + +impl HlsOriginEntryUrl { + pub(super) fn direct_http(url: impl Into) -> Self { Self::DirectHttp { url: url.into() } } + + pub(super) fn provider_failover(url: impl Into, provider: Arc) -> Self { + Self::ProviderFailover { url: url.into(), provider } + } + + pub(super) fn as_str(&self) -> &str { + match self { + Self::DirectHttp { url } | Self::ProviderFailover { url, .. } => url, + } + } + + pub(super) fn url_failover_provider(&self) -> Option> { + match self { + Self::DirectHttp { .. } => None, + Self::ProviderFailover { provider, .. } => Some(Arc::clone(provider)), + } + } +} + +pub(super) fn resolve_hls_cache_origin_entry_url(input: &ConfigInput, url: &str) -> Option { + if let Some(provider) = hls_url_failover_provider_for_origin_url(input, url) { + return Some(HlsCacheOriginResolution { + hls_url: url.to_string(), + session_entry_url: HlsOriginEntryUrl::provider_failover(url, provider), + }); + } + + let parsed = Url::parse(url).ok()?; + if matches!(parsed.scheme(), "http" | "https") { + return Some(HlsCacheOriginResolution { + hls_url: url.to_string(), + session_entry_url: HlsOriginEntryUrl::direct_http(url), + }); + } + + warn!("HLS origin entry URL is not supported: url={}", sanitize_sensitive_info(url)); + None +} + +pub(super) fn hls_url_failover_provider_for_origin_url(input: &ConfigInput, url: &str) -> Option> { + if !url.starts_with(PROVIDER_SCHEME_PREFIX) { + return None; + } + input.get_resolve_provider(url).map(|provider| Arc::clone(&provider)) +} + +pub(super) fn is_http_hls_origin_url(url: &str) -> bool { + Url::parse(url).is_ok_and(|parsed| matches!(parsed.scheme(), "http" | "https")) +} + +pub(super) fn is_supported_hls_origin_url(input: &ConfigInput, url: &str) -> bool { + input.get_resolve_provider(url).is_some() || is_http_hls_origin_url(url) +} + +pub(super) fn build_hls_origin_source(input: &ConfigInput, stream_ref: impl Into) -> HlsOriginSource { + HlsOriginSource::new(input.id, Arc::clone(&input.name), stream_ref, hls_origin_source_kind(input.input_type)) +} + +pub(super) fn build_hls_origin_source_for_playback( + input: &ConfigInput, + stream_ref: impl Into, + archive_reference: Option, + archive_url: Option<&str>, +) -> HlsOriginSource { + let source = build_hls_origin_source(input, stream_ref); + match (archive_reference, archive_url) { + (Some(timestamp), Some(url)) => source.with_archive_request(timestamp, url), + (Some(timestamp), None) => source.with_archive_reference(timestamp), + (None, _) => source, + } +} + +/// Keeps target routing identity separate from the immutable input content identity. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(in crate::api) struct HlsEntryStreamIdentity { + pub(super) virtual_id: u32, + pub(super) input_stream_id: Arc, + pub(super) upstream_user_agent: Option>, +} + +impl HlsEntryStreamIdentity { + pub(in crate::api) fn new(virtual_id: u32, input_stream_id: impl Into>) -> Option { + let input_stream_id = input_stream_id.into(); + if input_stream_id.trim().is_empty() { + return None; + } + Some(Self { virtual_id, input_stream_id, upstream_user_agent: None }) + } + + pub(in crate::api) fn from_playlist_item(item: &impl PlaylistEntry) -> Option { + let mut identity = Self::new(item.get_virtual_id().get(), item.get_input_stream_id()?)?; + identity.upstream_user_agent = item.get_upstream_user_agent().map(Internable::intern); + Some(identity) + } + + pub(in crate::api) const fn virtual_id(&self) -> u32 { self.virtual_id } + + pub(super) fn stream_ref(&self) -> &str { self.input_stream_id.as_ref() } + + pub(super) fn upstream_user_agent(&self) -> Option<&str> { self.upstream_user_agent.as_deref() } +} + +/// Immutable input identity plus bitrate metadata available at the virtual HLS entry. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(in crate::api) struct HlsEntryStreamContext { + pub(super) identity: HlsEntryStreamIdentity, + pub(super) known_bitrate_bps: Option, +} + +impl HlsEntryStreamContext { + pub(in crate::api) fn from_playlist_item(item: &impl PlaylistEntry) -> Option { + let identity = HlsEntryStreamIdentity::from_playlist_item(item)?; + let known_bitrate_bps = match item.get_additional_properties() { + Some(StreamProperties::Live(properties)) if properties.bitrate > 0 => Some(properties.bitrate), + Some( + StreamProperties::Live(_) + | StreamProperties::Video(_) + | StreamProperties::Series(_) + | StreamProperties::Episode(_), + ) + | None => None, + }; + Some(Self { identity, known_bitrate_bps }) + } + + pub(in crate::api) const fn virtual_id(&self) -> u32 { self.identity.virtual_id() } + + pub(in crate::api) fn stream_ref(&self) -> &str { self.identity.stream_ref() } + + pub(in crate::api) const fn known_bitrate_bps(&self) -> Option { self.known_bitrate_bps } + + pub(in crate::api) fn identity(&self) -> &HlsEntryStreamIdentity { &self.identity } +} + +/// Resolves the configured input together with both identities of one target entry. +#[derive(Debug, Clone)] +pub(in crate::api) struct HlsResolvedVirtualSource { + pub(in crate::api) input: Arc, + pub(in crate::api) stream_context: HlsEntryStreamContext, +} + +pub(super) fn hls_origin_source_kind(input_type: InputType) -> HlsOriginSourceKind { + if input_type.is_xtream() { + HlsOriginSourceKind::XtreamLive + } else if input_type.is_m3u() { + HlsOriginSourceKind::M3uMediaPlaylist + } else { + HlsOriginSourceKind::DirectMediaPlaylist + } +} + +pub(super) fn build_hls_origin_resolution( + input: &ConfigInput, + media_playlist_url: &str, +) -> Option { + let candidate = match hls_origin_source_kind(input.input_type) { + HlsOriginSourceKind::XtreamLive => { + ensure_hls_manifest_extension(&normalize_xtream_live_hls_url(media_playlist_url, input)) + } + HlsOriginSourceKind::M3uMediaPlaylist | HlsOriginSourceKind::DirectMediaPlaylist => { + ensure_hls_manifest_extension(media_playlist_url) + } + }; + resolve_hls_cache_origin_entry_url(input, &candidate) +} + +#[derive(Clone, Copy)] +pub(super) enum HlsOriginWorkKind { + Manifest, + Segment, + Resource, +} + +impl HlsOriginWorkKind { + pub(super) const fn as_log_value(self) -> &'static str { + match self { + Self::Manifest => "manifest", + Self::Segment => "segment", + Self::Resource => "resource", + } + } +} + +pub(super) fn build_hls_origin_fetch_url( + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + provider_config: Option<&Arc>, +) -> Option { + let provider_scheme_url = [session_entry_url, raw_request_url] + .into_iter() + .find(|url| hls_url_failover_provider_for_origin_url(input, url).is_some()); + let url = if let (Some(provider_config), Some(provider_scheme_url)) = (provider_config, provider_scheme_url) { + rewrite_hls_provider_scheme_origin_account(provider_scheme_url, input, provider_config)? + } else if let Some(provider_config) = provider_config { + get_stream_alternative_url(raw_request_url, input, provider_config) + .or_else(|| get_stream_alternative_url(session_entry_url, input, provider_config)) + .unwrap_or_else(|| session_entry_url.to_string()) + } else { + session_entry_url.to_string() + }; + + if is_supported_hls_origin_url(input, &url) { + Some(url) + } else { + None + } +} + +pub(super) fn rewrite_hls_provider_scheme_origin_account( + provider_scheme_url: &str, + input: &ConfigInput, + provider_config: &Arc, +) -> Option { + if !provider_scheme_url.starts_with(PROVIDER_SCHEME_PREFIX) { + return None; + } + let alt_input_user_info = provider_config.get_user_info()?; + let Some((_source_base_url, source_username, source_password)) = + input.get_matched_config_by_url(provider_scheme_url) + else { + return Some(provider_scheme_url.to_string()); + }; + let (Some(old_username), Some(old_password)) = (source_username, source_password) else { + return Some(provider_scheme_url.to_string()); + }; + + let mut url = Url::parse(provider_scheme_url).ok()?; + if rewrite_hls_url_auth_fields( + &mut url, + old_username, + old_password, + &alt_input_user_info.username, + &alt_input_user_info.password, + ) { + Some(url.to_string()) + } else { + None + } +} + +pub(super) fn rewrite_hls_url_auth_fields( + url: &mut Url, + old_username: &str, + old_password: &str, + new_username: &str, + new_password: &str, +) -> bool { + if rewrite_hls_query_auth_fields(url, new_username, new_password) { + return true; + } + + if url.username() == old_username && url.password() == Some(old_password) { + return url.set_username(new_username).is_ok() && url.set_password(Some(new_password)).is_ok(); + } + + rewrite_hls_path_auth_fields(url, old_username, old_password, new_username, new_password) +} + +pub(super) fn rewrite_hls_query_auth_fields(url: &mut Url, new_username: &str, new_password: &str) -> bool { + let mut has_username = false; + let mut has_password = false; + let pairs: Vec<(String, String)> = url + .query_pairs() + .map(|(key, value)| { + if key.eq_ignore_ascii_case("username") { + has_username = true; + (key.into_owned(), new_username.to_string()) + } else if key.eq_ignore_ascii_case("password") { + has_password = true; + (key.into_owned(), new_password.to_string()) + } else { + (key.into_owned(), value.into_owned()) + } + }) + .collect(); + + if !(has_username && has_password) { + return false; + } + + url.query_pairs_mut().clear().extend_pairs(pairs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + true +} + +pub(super) fn rewrite_hls_path_auth_fields( + url: &mut Url, + old_username: &str, + old_password: &str, + new_username: &str, + new_password: &str, +) -> bool { + let Some(mut segments) = url.path_segments().map(|segments| segments.map(ToOwned::to_owned).collect::>()) + 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 Some(credential_index) = credential_index else { + return false; + }; + + segments[credential_index] = new_username.to_string(); + segments[credential_index + 1] = new_password.to_string(); + + let Ok(mut path_segments) = url.path_segments_mut() else { + return false; + }; + path_segments.clear().extend(segments.iter().map(String::as_str)); + true +} + +pub(super) fn hls_url_failover_provider_for_origin_context( + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + fetch_url: &str, +) -> Option> { + hls_url_failover_provider_for_origin_url(input, session_entry_url) + .or_else(|| hls_url_failover_provider_for_origin_url(input, raw_request_url)) + .or_else(|| hls_url_failover_provider_for_origin_url(input, fetch_url)) +} + +pub(super) struct PreparedHlsOriginRuntime { + pub(super) fetch_url: String, + pub(super) runtime_provider_config: Option>, + // URL failover comes from source.yml provider:// resolution. Origin-account + // binding/handles are runtime account reservations and must stay separate. + pub(super) url_failover_provider: Option>, + pub(super) origin_account_binding_to_store: Option, + pub(super) preacquired_origin_account_handle: Option, +} + +pub(super) fn effective_hls_url_failover_provider_for_fetch_url( + fetch_url: &str, + prepared_url_failover_provider: Option>, + origin_url_failover_provider: Option>, +) -> Option> { + if !fetch_url.starts_with(PROVIDER_SCHEME_PREFIX) { + return None; + } + prepared_url_failover_provider.or(origin_url_failover_provider) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HlsOriginRuntimeAcquireError { + NoAccountAvailable { reason: HlsOriginRuntimeNoAccountReason }, + Fatal(StatusCode), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HlsOriginRuntimeNoAccountReason { + ProviderConnectionsExhausted, + OriginBindingPreempted, +} + +pub(super) fn hls_no_account_reason_for_binding( + binding: Option<&HlsOriginAccountBinding>, +) -> HlsOriginRuntimeNoAccountReason { + let Some(binding) = binding else { + return HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted; + }; + match &binding.binding_mode { + HlsOriginAccountBindingMode::Detached { + reason: HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, + .. + } + | HlsOriginAccountBindingMode::Detached { + reason: HlsOriginAccountDetachedReason::PreemptedByHigherPriority, + .. + } => HlsOriginRuntimeNoAccountReason::OriginBindingPreempted, + HlsOriginAccountBindingMode::Detached { .. } + | HlsOriginAccountBindingMode::Active + | HlsOriginAccountBindingMode::Speculative { .. } => { + HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted + } + } +} + +#[derive(Clone)] +pub(super) struct HlsAccountOverlapCandidate { + pub(super) proxy_session_id: ProxySessionId, + pub(super) input_name: Arc, + pub(super) account_name: Arc, + pub(super) session_owner: String, + pub(super) reservation_ttl_secs: u64, + pub(super) reclaim_until_ms: u64, + pub(super) last_media_at_ms: u64, + pub(super) soft_overlap_eligible_at_ms: u64, + pub(super) soft_overlap_delay_ms: u64, + pub(super) tuliprox_target_user_connection_capacity: u32, + pub(super) origin_input_account_connection_capacity: u32, +} + +#[derive(Clone)] +pub(super) struct HlsOriginPolicyPreemptCandidate { + pub(super) session: HlsSessionHandle, + pub(super) proxy_session_id: ProxySessionId, + pub(super) account_name: Arc, + pub(super) session_owner: String, + pub(super) reservation_ttl_secs: u64, + pub(super) victim_policy: HlsEffectiveOriginAcquirePolicy, + pub(super) last_media_at_ms: u64, +} + +trait HlsOriginReservationCandidate { + fn account_name(&self) -> &Arc; + fn session_owner(&self) -> &str; + fn reservation_ttl_secs(&self) -> u64; +} + +impl HlsOriginReservationCandidate for HlsAccountOverlapCandidate { + fn account_name(&self) -> &Arc { &self.account_name } + + fn session_owner(&self) -> &str { &self.session_owner } + + fn reservation_ttl_secs(&self) -> u64 { self.reservation_ttl_secs } +} + +impl HlsOriginReservationCandidate for HlsOriginPolicyPreemptCandidate { + fn account_name(&self) -> &Arc { &self.account_name } + + fn session_owner(&self) -> &str { &self.session_owner } + + fn reservation_ttl_secs(&self) -> u64 { self.reservation_ttl_secs } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct HlsSoftOverlapCapacity { + pub(super) tuliprox_target_user_connection_capacity: u32, + pub(super) origin_input_account_connection_capacity: u32, + pub(super) delay_ms: u64, +} + +pub(super) fn hls_soft_overlap_capacity_for_target_duration( + tuliprox_target_user_connection_capacity: u32, + origin_input_account_connection_capacity: u32, + target_duration_ms: u64, +) -> HlsSoftOverlapCapacity { + let delay_ms = hls_soft_overlap_delay_ms( + target_duration_ms, + tuliprox_target_user_connection_capacity, + origin_input_account_connection_capacity, + ); + HlsSoftOverlapCapacity { + tuliprox_target_user_connection_capacity, + origin_input_account_connection_capacity, + delay_ms, + } +} + +pub(super) async fn hls_origin_input_account_connection_capacity( + app_state: &Arc, + input: &ConfigInput, +) -> u32 { + let capacities = app_state.active_provider.provider_capacities_for_input(&input.name).await; + if capacities.is_empty() { + return hls_configured_origin_input_account_connection_capacity(input); + } + capacities + .into_iter() + .map(|(_, _, max)| if max == 0 { u32::MAX } else { u32::try_from(max).unwrap_or(u32::MAX) }) + .fold(0u32, u32::saturating_add) + .max(1) +} + +pub(super) fn hls_configured_origin_input_account_connection_capacity(input: &ConfigInput) -> u32 { + let input_capacity = if input.max_connections == 0 { 1 } else { u32::from(input.max_connections) }; + input + .aliases + .as_ref() + .map_or(0, |aliases| { + aliases + .iter() + .filter(|alias| alias.enabled) + .map(|alias| if alias.max_connections == 0 { 1 } else { u32::from(alias.max_connections) }) + .fold(0u32, u32::saturating_add) + }) + .saturating_add(input_capacity) + .max(1) +} + +pub(super) async fn hls_tuliprox_target_user_connection_capacity( + app_state: &Arc, + input: &ConfigInput, +) -> u32 { + hls_configured_tuliprox_target_user_connection_capacity(app_state, input) + .max(hls_active_tuliprox_target_user_connections_for_input(app_state, input).await) + .max(1) +} + +pub(super) fn hls_configured_tuliprox_target_user_connection_capacity( + app_state: &Arc, + input: &ConfigInput, +) -> u32 { + let Some(api_proxy) = app_state.app_config.api_proxy.load().as_ref().cloned() else { + return 1; + }; + api_proxy + .user + .iter() + .filter(|target_user| { + app_state + .app_config + .get_inputs_for_target(&target_user.target) + .is_some_and(|inputs| inputs.iter().any(|candidate| candidate.name == input.name)) + }) + .map(|target_user| { + target_user + .credentials + .iter() + .map(|user| { + if user.max_connections == 0 { + u32::MAX + } else { + user.max_connections.saturating_add(u32::from(user.soft_connections)) + } + }) + .fold(0u32, u32::saturating_add) + }) + .max() + .unwrap_or(1) + .max(1) +} + +pub(super) async fn hls_active_tuliprox_target_user_connections_for_input( + app_state: &Arc, + input: &ConfigInput, +) -> u32 { + u32::try_from( + app_state + .active_users + .active_streams() + .await + .iter() + .filter(|stream| stream.channel.input_name == input.name) + .count(), + ) + .unwrap_or(u32::MAX) +} + +pub(super) fn hls_soft_overlap_delay_ms( + target_duration_ms: u64, + tuliprox_target_user_connection_capacity: u32, + origin_input_account_connection_capacity: u32, +) -> u64 { + let target_duration_ms = target_duration_ms.max(1); + let users = u64::from(tuliprox_target_user_connection_capacity.max(1)); + let origin = u64::from(origin_input_account_connection_capacity.max(1)); + if users >= origin.saturating_mul(2) { + return target_duration_ms; + } + if users <= origin { + return target_duration_ms.saturating_mul(2); + } + let numerator = origin.saturating_mul(3).saturating_sub(users); + target_duration_ms.saturating_mul(numerator).saturating_add(origin.saturating_sub(1)) / origin +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(super) async fn prepare_hls_origin_runtime( + app_state: &Arc, + session: &HlsSessionHandle, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + proxy_session_id: &ProxySessionId, + fingerprint: &Fingerprint, + connection_kind: crate::api::model::ConnectionKind, + priority: i8, + work_kind: HlsOriginWorkKind, + work_class: HlsOriginWorkClass, + now_ms: u64, +) -> Result { + promote_elapsed_hls_account_overlaps(app_state, now_ms).await; + detach_unprotected_hls_origin_account_bindings(app_state, now_ms).await; + reclaim_hls_account_overlap_if_needed(app_state, session, now_ms).await; + let existing_binding = session.read().await.origin_account_binding.clone(); + let reacquire_detached_binding = existing_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_detached); + let final_no_account_reason = hls_no_account_reason_for_binding(existing_binding.as_ref()); + if reacquire_detached_binding { + log_hls_origin_binding_reacquire_started(session, work_kind).await; + } + if let Some(binding) = existing_binding { + if binding.is_active() { + match hls_origin_account_status(&app_state.hls_ctx(), &binding) { + stale_status @ (HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired) => { + return rebind_hls_origin_account( + app_state, + session, + input, + raw_request_url, + session_entry_url, + &binding, + stale_status, + fingerprint, + connection_kind, + priority, + now_ms, + ) + .await; + } + HlsOriginAccountStatus::Known => { + return Ok(prepared_hls_origin_runtime_for_known_binding( + app_state, + input, + raw_request_url, + session_entry_url, + &binding, + )); + } + } + } + } + + match prepare_hls_origin_runtime_with_new_account( + app_state, + input, + raw_request_url, + session_entry_url, + proxy_session_id, + fingerprint, + connection_kind, + priority, + false, + work_kind, + work_class, + now_ms, + ) + .await + { + Ok(prepared) => { + if reacquire_detached_binding { + if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { + log_hls_origin_binding_reacquired(session, binding).await; + } + } + return Ok(prepared); + } + Err(HlsOriginRuntimeAcquireError::Fatal(status)) => return Err(HlsOriginRuntimeAcquireError::Fatal(status)), + Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { .. }) => {} + } + + if work_class.allows_speculative_overlap() { + if let Ok(prepared) = prepare_hls_origin_policy_preempt_runtime( + app_state, + session, + input, + raw_request_url, + session_entry_url, + proxy_session_id, + fingerprint, + connection_kind, + priority, + now_ms, + ) + .await + { + if reacquire_detached_binding { + if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { + log_hls_origin_binding_reacquired(session, binding).await; + } + } + return Ok(prepared); + } + + if let Ok(prepared) = prepare_hls_speculative_origin_runtime( + app_state, + session, + input, + raw_request_url, + session_entry_url, + proxy_session_id, + fingerprint, + connection_kind, + priority, + now_ms, + ) + .await + { + if reacquire_detached_binding { + if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { + log_hls_origin_binding_reacquired(session, binding).await; + } + } + return Ok(prepared); + } + } else { + debug!("HLS account overlap skipped: work_class={} reason=background-origin-work", work_class.as_log_value()); + } + + if work_class.allows_grace() { + match prepare_hls_origin_runtime_with_new_account( + app_state, + input, + raw_request_url, + session_entry_url, + proxy_session_id, + fingerprint, + connection_kind, + priority, + true, + work_kind, + work_class, + now_ms, + ) + .await + { + Ok(prepared) => { + if reacquire_detached_binding { + if let Some(binding) = prepared.origin_account_binding_to_store.as_ref() { + log_hls_origin_binding_reacquired(session, binding).await; + } + } + return Ok(prepared); + } + Err(HlsOriginRuntimeAcquireError::Fatal(status)) => { + return Err(HlsOriginRuntimeAcquireError::Fatal(status)) + } + Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { .. }) => {} + } + } else { + debug!( + "HLS origin account grace skipped: work_class={} reason=background-origin-work", + work_class.as_log_value() + ); + } + + if reacquire_detached_binding { + log_hls_origin_binding_reacquire_failed(session, "no-account-available").await; + } + Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { reason: final_no_account_reason }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_hls_origin_runtime_with_new_account( + app_state: &Arc, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + proxy_session_id: &ProxySessionId, + fingerprint: &Fingerprint, + connection_kind: crate::api::model::ConnectionKind, + priority: i8, + allow_grace: bool, + work_kind: HlsOriginWorkKind, + work_class: HlsOriginWorkClass, + now_ms: u64, +) -> Result { + let session_owner = build_hls_origin_session_owner(proxy_session_id); + let Some(provider_handle) = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &fingerprint.addr, + allow_grace, + priority, + connection_kind, + Some(&session_owner), + ) + .await + else { + debug!( + "HLS origin account acquire unavailable: work={} work_class={} grace={}", + work_kind.as_log_value(), + work_class.as_log_value(), + if allow_grace { "attempted" } else { "disabled" } + ); + return Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { + reason: HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted, + }); + }; + + let Some(provider_config) = provider_handle.allocation.get_provider_config() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let Some(binding) = origin_account_binding_from_allocation( + Arc::clone(&input.name), + proxy_session_id, + &provider_handle.allocation, + now_ms, + ) else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let grace_state = if matches!(provider_handle.allocation, ProviderAllocation::GracePeriod(_)) { + "granted" + } else if allow_grace { + "not-needed" + } else { + "disabled" + }; + debug!( + "HLS origin account binding created: account={} owner={} work={} work_class={} grace={}", + sanitize_sensitive_info(binding.account_name.as_ref()), + sanitize_sensitive_info(&binding.session_owner), + work_kind.as_log_value(), + work_class.as_log_value(), + grace_state + ); + + Ok(PreparedHlsOriginRuntime { + url_failover_provider: hls_url_failover_provider_for_origin_context( + input, + raw_request_url, + session_entry_url, + &fetch_url, + ), + runtime_provider_config: Some(provider_config), + fetch_url, + origin_account_binding_to_store: Some(binding), + preacquired_origin_account_handle: Some(provider_handle), + }) +} + +pub(super) fn prepared_hls_origin_runtime_for_known_binding( + app_state: &Arc, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + binding: &HlsOriginAccountBinding, +) -> PreparedHlsOriginRuntime { + let runtime_provider_config = app_state.active_provider.find_provider_config(&binding.account_name); + let fetch_url = runtime_provider_config + .as_ref() + .and_then(|provider_config| { + build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(provider_config)) + }) + .unwrap_or_else(|| session_entry_url.to_string()); + + PreparedHlsOriginRuntime { + runtime_provider_config, + url_failover_provider: hls_url_failover_provider_for_origin_context( + input, + raw_request_url, + session_entry_url, + &fetch_url, + ), + fetch_url, + origin_account_binding_to_store: None, + preacquired_origin_account_handle: None, + } +} + +pub(super) async fn log_hls_origin_binding_reacquire_started(session: &HlsSessionHandle, work_kind: HlsOriginWorkKind) { + let session_guard = session.read().await; + let mode = match session_guard.mode { + HlsSessionMode::NormalCacheTimeline => "normal", + HlsSessionMode::TransientPassthrough { .. } => "transient", + }; + debug!( + "HLS origin binding reacquire started: proxy_session={} mode={} work={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + mode, + work_kind.as_log_value() + ); +} + +pub(super) async fn log_hls_origin_binding_reacquired(session: &HlsSessionHandle, binding: &HlsOriginAccountBinding) { + let session_guard = session.read().await; + debug!( + "HLS origin binding reacquired: proxy_session={} account={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(binding.account_name.as_ref()) + ); +} + +pub(super) async fn log_hls_origin_binding_reacquire_failed(session: &HlsSessionHandle, reason: &str) { + let session_guard = session.read().await; + debug!( + "HLS origin binding reacquire failed: proxy_session={} reason={} retry_after_ms={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + reason, + cold_start_retry_after_seconds().saturating_mul(1_000) + ); +} + +pub(super) async fn detach_unprotected_hls_origin_account_bindings(app_state: &Arc, now_ms: u64) { + let sessions = app_state.hls_proxy.sessions().list_sessions().await; + for session in sessions { + let binding = { + let mut session_guard = session.write().await; + let Some(binding) = session_guard.origin_account_binding.clone() else { + continue; + }; + if !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { + continue; + } + let timing = session_guard.account_overlap_timing(); + let protection = session_guard.account_binding_protection(now_ms); + debug!( + "HLS account protection classified: proxy_session={} state={} target_duration_ms={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + protection.as_log_state(), + timing.target_duration_ms + ); + if !matches!(protection, HlsAccountBindingProtection::Expired) + || session_guard.activity.active_origin_work_count > 0 + { + continue; + } + if !matches!(hls_origin_account_status(&app_state.hls_ctx(), &binding), HlsOriginAccountStatus::Known) { + continue; + } + if let Some(binding) = session_guard.origin_account_binding.as_mut() { + binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, now_ms); + } + session_guard.invalidate_queued_origin_work(); + debug!( + "HLS origin binding detached: proxy_session={} account={} reason={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(binding.account_name.as_ref()), + HlsOriginAccountDetachedReason::SoftWindowElapsed.as_log_reason() + ); + binding + }; + app_state.active_provider.clear_provider_reservation(&binding.session_owner).await; + } +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub(super) async fn prepare_hls_origin_policy_preempt_runtime( + app_state: &Arc, + new_session: &HlsSessionHandle, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + proxy_session_id: &ProxySessionId, + fingerprint: &Fingerprint, + connection_kind: crate::api::model::ConnectionKind, + priority: i8, + now_ms: u64, +) -> Result { + let request_policy = HlsEffectiveOriginAcquirePolicy::new(connection_kind, priority, now_ms); + let Some(candidate) = + find_hls_origin_policy_preempt_candidate(app_state, input, proxy_session_id, request_policy, now_ms).await + else { + debug!("HLS origin policy preemption denied: reason=no-lower-origin-policy-candidate"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + app_state.active_provider.clear_provider_reservation(&candidate.session_owner).await; + let session_owner = build_hls_origin_session_owner(proxy_session_id); + let Some(provider_handle) = app_state + .active_provider + .acquire_exact_connection_with_grace_for_session( + &candidate.account_name, + &fingerprint.addr, + false, + priority, + connection_kind, + Some(&session_owner), + ) + .await + else { + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS origin policy preemption denied: reason=exact-acquire-failed"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let Some(provider_config) = provider_handle.allocation.get_provider_config() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS origin policy preemption denied: reason=missing-provider-config"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS origin policy preemption denied: reason=invalid-origin-url"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let Some(binding) = origin_account_binding_from_allocation( + Arc::clone(&input.name), + proxy_session_id, + &provider_handle.allocation, + now_ms, + ) else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS origin policy preemption denied: reason=invalid-allocation"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let mut detached_victim = false; + { + let mut victim = candidate.session.write().await; + let has_no_active_origin_work = victim.activity.active_origin_work_count == 0; + if let Some(victim_binding) = victim.origin_account_binding.as_mut() { + if victim_binding.account_name == candidate.account_name + && victim_binding.session_owner == candidate.session_owner + && matches!(victim_binding.binding_mode, HlsOriginAccountBindingMode::Active) + && has_no_active_origin_work + { + victim_binding.detach(HlsOriginAccountDetachedReason::PreemptedByHigherPriority, now_ms); + detached_victim = true; + } + } + if detached_victim { + victim.invalidate_queued_origin_work(); + } + } + if !detached_victim { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS origin policy preemption denied: reason=stale-candidate"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + } + + { + let mut session_guard = new_session.write().await; + session_guard.replace_origin_account_binding(Some(binding.clone())); + } + debug!( + "HLS origin policy preempted: account={} victim_proxy_session={} winner_proxy_session={} victim_kind={:?} victim_priority={} request_kind={:?} request_priority={}", + sanitize_sensitive_info(candidate.account_name.as_ref()), + safe_proxy_session_id(&candidate.proxy_session_id), + safe_proxy_session_id(proxy_session_id), + candidate.victim_policy.connection_kind, + candidate.victim_policy.priority, + request_policy.connection_kind, + request_policy.priority + ); + debug!( + "HLS origin binding detached: proxy_session={} account={} reason={}", + safe_proxy_session_id(&candidate.proxy_session_id), + sanitize_sensitive_info(candidate.account_name.as_ref()), + HlsOriginAccountDetachedReason::PreemptedByHigherPriority.as_log_reason() + ); + + Ok(PreparedHlsOriginRuntime { + url_failover_provider: hls_url_failover_provider_for_origin_context( + input, + raw_request_url, + session_entry_url, + &fetch_url, + ), + runtime_provider_config: Some(provider_config), + fetch_url, + origin_account_binding_to_store: Some(binding), + preacquired_origin_account_handle: Some(provider_handle), + }) +} + +async fn restore_hls_origin_policy_preempt_candidate_reservation( + app_state: &Arc, + candidate: &C, +) { + app_state + .active_provider + .refresh_provider_reservation( + candidate.account_name(), + candidate.session_owner(), + candidate.reservation_ttl_secs(), + ) + .await; +} + +pub(super) async fn find_hls_origin_policy_preempt_candidate( + app_state: &Arc, + input: &ConfigInput, + new_proxy_session_id: &ProxySessionId, + request_policy: HlsEffectiveOriginAcquirePolicy, + _now_ms: u64, +) -> Option { + let sessions = app_state.hls_proxy.sessions().list_sessions().await; + let mut best_candidate = None; + for session in sessions { + let session_guard = session.read().await; + if session_guard.proxy_session_id == *new_proxy_session_id { + continue; + } + let Some(binding) = session_guard.origin_account_binding.as_ref() else { + continue; + }; + if binding.input_name != input.name || !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { + continue; + } + if session_guard.activity.active_origin_work_count > 0 { + continue; + } + if !matches!(hls_origin_account_status(&app_state.hls_ctx(), binding), HlsOriginAccountStatus::Known) { + continue; + } + let victim_policy = session_guard.effective_origin_acquire_policy_or_default(); + if !request_policy.is_better_than(victim_policy) { + continue; + } + let candidate = HlsOriginPolicyPreemptCandidate { + session: Arc::clone(&session), + proxy_session_id: session_guard.proxy_session_id.clone(), + account_name: Arc::clone(&binding.account_name), + session_owner: binding.session_owner.clone(), + reservation_ttl_secs: session_guard.account_overlap_timing().reservation_ttl_secs(), + victim_policy, + last_media_at_ms: session_guard.activity.last_authorized_media_at_ms.unwrap_or_default(), + }; + if hls_origin_policy_preempt_candidate_is_better(best_candidate.as_ref(), &candidate) { + best_candidate = Some(candidate); + } + } + best_candidate +} + +pub(super) fn hls_origin_policy_preempt_candidate_is_better( + current: Option<&HlsOriginPolicyPreemptCandidate>, + candidate: &HlsOriginPolicyPreemptCandidate, +) -> bool { + let Some(current) = current else { + return true; + }; + match (candidate.victim_policy.connection_kind, current.victim_policy.connection_kind) { + (crate::api::model::ConnectionKind::Soft, crate::api::model::ConnectionKind::Normal) => return true, + (crate::api::model::ConnectionKind::Normal, crate::api::model::ConnectionKind::Soft) => return false, + _ => {} + } + candidate.victim_policy.priority > current.victim_policy.priority + || (candidate.victim_policy.priority == current.victim_policy.priority + && candidate.last_media_at_ms < current.last_media_at_ms) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_hls_speculative_origin_runtime( + app_state: &Arc, + new_session: &HlsSessionHandle, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + proxy_session_id: &ProxySessionId, + fingerprint: &Fingerprint, + connection_kind: crate::api::model::ConnectionKind, + priority: i8, + now_ms: u64, +) -> Result { + let Some(candidate) = find_hls_account_overlap_candidate(app_state, input, proxy_session_id, now_ms).await else { + debug!("HLS account overlap denied: reason=no-soft-active-candidate"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + app_state.active_provider.clear_provider_reservation(&candidate.session_owner).await; + let session_owner = build_hls_origin_session_owner(proxy_session_id); + let Some(provider_handle) = app_state + .active_provider + .acquire_exact_connection_with_grace_for_session( + &candidate.account_name, + &fingerprint.addr, + false, + priority, + connection_kind, + Some(&session_owner), + ) + .await + else { + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS account overlap denied: reason=speculative-acquire-failed"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let Some(provider_config) = provider_handle.allocation.get_provider_config() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS account overlap denied: reason=missing-provider-config"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + restore_hls_origin_policy_preempt_candidate_reservation(app_state, &candidate).await; + debug!("HLS account overlap denied: reason=invalid-origin-url"); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let binding = HlsOriginAccountBinding::speculative_from( + Arc::clone(&input.name), + Arc::clone(&candidate.account_name), + proxy_session_id, + candidate.proxy_session_id.clone(), + candidate.reclaim_until_ms, + now_ms, + ); + { + let mut session_guard = new_session.write().await; + session_guard.replace_origin_account_binding(Some(binding.clone())); + } + debug!( + "HLS account overlap granted: account={} victim_proxy_session={} winner_proxy_session={} reclaim_until_ms={} eligible_after_ms={} delay_ms={} tuliprox_target_user_connections={} origin_input_account_connections={}", + sanitize_sensitive_info(candidate.account_name.as_ref()), + safe_proxy_session_id(&candidate.proxy_session_id), + safe_proxy_session_id(proxy_session_id), + candidate.reclaim_until_ms, + candidate.soft_overlap_eligible_at_ms, + candidate.soft_overlap_delay_ms, + candidate.tuliprox_target_user_connection_capacity, + candidate.origin_input_account_connection_capacity + ); + Ok(PreparedHlsOriginRuntime { + url_failover_provider: hls_url_failover_provider_for_origin_context( + input, + raw_request_url, + session_entry_url, + &fetch_url, + ), + runtime_provider_config: Some(provider_config), + fetch_url, + origin_account_binding_to_store: Some(binding), + preacquired_origin_account_handle: Some(provider_handle), + }) +} + +pub(super) async fn find_hls_account_overlap_candidate( + app_state: &Arc, + input: &ConfigInput, + new_proxy_session_id: &ProxySessionId, + now_ms: u64, +) -> Option { + let sessions = app_state.hls_proxy.sessions().list_sessions().await; + let tuliprox_target_user_connection_capacity = hls_tuliprox_target_user_connection_capacity(app_state, input).await; + let origin_input_account_connection_capacity = hls_origin_input_account_connection_capacity(app_state, input).await; + let mut speculative_accounts = Vec::new(); + for session in &sessions { + let session = session.read().await; + let Some(binding) = session.origin_account_binding.as_ref() else { + continue; + }; + if binding.input_name != input.name { + continue; + } + if matches!( + binding.binding_mode, + HlsOriginAccountBindingMode::Speculative { reclaim_until_ms, .. } if now_ms <= reclaim_until_ms + ) { + speculative_accounts.push(Arc::clone(&binding.account_name)); + } + } + + let mut candidates = Vec::new(); + for session in sessions { + let session_guard = session.read().await; + if session_guard.proxy_session_id == *new_proxy_session_id { + continue; + } + let Some(binding) = session_guard.origin_account_binding.as_ref() else { + continue; + }; + if binding.input_name != input.name + || speculative_accounts.iter().any(|account| account == &binding.account_name) + { + continue; + } + if !matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active) { + continue; + } + if session_guard.activity.active_origin_work_count > 0 { + continue; + } + let timing = session_guard.account_overlap_timing(); + let protection = session_guard.account_binding_protection(now_ms); + debug!( + "HLS account protection classified: proxy_session={} state={} target_duration_ms={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + protection.as_log_state(), + timing.target_duration_ms + ); + let HlsAccountBindingProtection::SoftActive { reclaim_until_ms } = protection else { + continue; + }; + let last_media_at_ms = session_guard.activity.last_authorized_media_at_ms.unwrap_or_default(); + let capacity = hls_soft_overlap_capacity_for_target_duration( + tuliprox_target_user_connection_capacity, + origin_input_account_connection_capacity, + timing.target_duration_ms, + ); + let eligible_at_ms = last_media_at_ms.saturating_add(capacity.delay_ms); + if now_ms < eligible_at_ms { + debug!( + "HLS account overlap waiting: proxy_session={} account={} eligible_at_ms={} now_ms={} delay_ms={} tuliprox_target_user_connections={} origin_input_account_connections={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(binding.account_name.as_ref()), + eligible_at_ms, + now_ms, + capacity.delay_ms, + capacity.tuliprox_target_user_connection_capacity, + capacity.origin_input_account_connection_capacity + ); + continue; + } + candidates.push(HlsAccountOverlapCandidate { + proxy_session_id: session_guard.proxy_session_id.clone(), + input_name: Arc::clone(&binding.input_name), + account_name: Arc::clone(&binding.account_name), + session_owner: binding.session_owner.clone(), + reservation_ttl_secs: timing.reservation_ttl_secs(), + reclaim_until_ms, + last_media_at_ms, + soft_overlap_eligible_at_ms: eligible_at_ms, + soft_overlap_delay_ms: capacity.delay_ms, + tuliprox_target_user_connection_capacity: capacity.tuliprox_target_user_connection_capacity, + origin_input_account_connection_capacity: capacity.origin_input_account_connection_capacity, + }); + } + let mut eligible = filter_hls_account_overlap_cooldowns(app_state, candidates, now_ms).await; + eligible.sort_by_key(|candidate| (candidate.last_media_at_ms, candidate.soft_overlap_eligible_at_ms)); + eligible.into_iter().next() +} + +pub(super) async fn filter_hls_account_overlap_cooldowns( + app_state: &Arc, + candidates: Vec, + now_ms: u64, +) -> Vec { + let mut eligible = Vec::new(); + for candidate in candidates { + if app_state + .hls_proxy + .is_account_overlap_cooling_down(&candidate.input_name, &candidate.account_name, now_ms) + .await + { + debug!( + "HLS account overlap skipped: proxy_session={} account={} reason=cooldown-active", + safe_proxy_session_id(&candidate.proxy_session_id), + sanitize_sensitive_info(candidate.account_name.as_ref()) + ); + continue; + } + eligible.push(candidate); + } + eligible +} + +pub(super) async fn reclaim_hls_account_overlap_if_needed( + app_state: &Arc, + winner_session: &HlsSessionHandle, + now_ms: u64, +) { + let winner_proxy_session_id = winner_session.read().await.proxy_session_id.clone(); + let sessions = app_state.hls_proxy.sessions().list_sessions().await; + for session in sessions { + let (loser_proxy_session_id, loser_binding) = { + let session_guard = session.read().await; + let Some(binding) = session_guard.origin_account_binding.clone() else { + continue; + }; + let HlsOriginAccountBindingMode::Speculative { displaced_proxy_session_id, reclaim_until_ms } = + &binding.binding_mode + else { + continue; + }; + if displaced_proxy_session_id != &winner_proxy_session_id || now_ms > *reclaim_until_ms { + continue; + } + (session_guard.proxy_session_id.clone(), binding) + }; + app_state.active_provider.clear_provider_reservation(&loser_binding.session_owner).await; + { + let mut loser = session.write().await; + if let Some(binding) = loser.origin_account_binding.as_mut() { + binding.detach(HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, now_ms); + } + loser.invalidate_queued_origin_work(); + } + { + let mut winner = winner_session.write().await; + if let Some(binding) = winner.origin_account_binding.as_mut() { + binding.promote_to_active(); + } + } + let hard_active_window_ms = winner_session.read().await.account_overlap_timing().hard_active_window_ms; + app_state + .hls_proxy + .mark_account_overlap_reclaimed_cooldown( + Arc::clone(&loser_binding.input_name), + Arc::clone(&loser_binding.account_name), + now_ms, + hard_active_window_ms, + ) + .await; + debug!( + "HLS account overlap reclaimed: account={} winner={} loser={}", + sanitize_sensitive_info(loser_binding.account_name.as_ref()), + safe_proxy_session_id(&winner_proxy_session_id), + safe_proxy_session_id(&loser_proxy_session_id) + ); + debug!( + "HLS origin binding detached: proxy_session={} account={} reason={}", + safe_proxy_session_id(&loser_proxy_session_id), + sanitize_sensitive_info(loser_binding.account_name.as_ref()), + HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner.as_log_reason() + ); + } +} + +pub(super) async fn promote_elapsed_hls_account_overlaps(app_state: &Arc, now_ms: u64) { + let sessions = app_state.hls_proxy.sessions().list_sessions().await; + for session in sessions { + let (input_name, account_name, promoted_session_id, displaced_session_id, hard_active_window_ms) = { + let mut session_guard = session.write().await; + let hard_active_window_ms = session_guard.account_overlap_timing().hard_active_window_ms; + let Some(binding) = session_guard.origin_account_binding.as_mut() else { + continue; + }; + let HlsOriginAccountBindingMode::Speculative { displaced_proxy_session_id, reclaim_until_ms } = + &binding.binding_mode + else { + continue; + }; + if now_ms <= *reclaim_until_ms { + continue; + } + let displaced_session_id = displaced_proxy_session_id.clone(); + let input_name = Arc::clone(&binding.input_name); + let account_name = Arc::clone(&binding.account_name); + binding.promote_to_active(); + ( + input_name, + account_name, + session_guard.proxy_session_id.clone(), + displaced_session_id, + hard_active_window_ms, + ) + }; + app_state + .hls_proxy + .mark_account_overlap_promoted_cooldown( + Arc::clone(&input_name), + Arc::clone(&account_name), + now_ms, + hard_active_window_ms, + ) + .await; + if let Some(displaced) = app_state.hls_proxy.sessions().get_by_proxy_session_id(&displaced_session_id).await { + let mut detached = false; + let mut displaced = displaced.write().await; + if displaced.origin_account_binding.as_ref().is_some_and(|binding| binding.account_name == account_name) { + if let Some(binding) = displaced.origin_account_binding.as_mut() { + binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, now_ms); + detached = true; + } + displaced.invalidate_queued_origin_work(); + } + if detached { + debug!( + "HLS origin binding detached: proxy_session={} account={} reason={}", + safe_proxy_session_id(&displaced_session_id), + sanitize_sensitive_info(account_name.as_ref()), + HlsOriginAccountDetachedReason::SoftWindowElapsed.as_log_reason() + ); + } + } + debug!( + "HLS account overlap promoted: account={} proxy_session={}", + sanitize_sensitive_info(account_name.as_ref()), + safe_proxy_session_id(&promoted_session_id) + ); + } +} + +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +pub(super) async fn rebind_hls_origin_account( + app_state: &Arc, + session: &HlsSessionHandle, + input: &ConfigInput, + raw_request_url: &str, + session_entry_url: &str, + stale_binding: &HlsOriginAccountBinding, + stale_status: HlsOriginAccountStatus, + fingerprint: &Fingerprint, + connection_kind: crate::api::model::ConnectionKind, + priority: i8, + now_ms: u64, +) -> Result { + { + let mut session_guard = session.write().await; + if !session_guard.origin_account_rebind.is_allowed_now(now_ms) { + debug!( + "HLS origin account rebind skipped by backoff: proxy_session={} old_account={} retry_after_ms=2000", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(stale_binding.account_name.as_ref()) + ); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + } + session_guard.origin_account_rebind.mark_attempt_started(Arc::clone(&stale_binding.account_name), now_ms); + } + + let safe_proxy_session = { + let session_guard = session.read().await; + safe_proxy_session_id(&session_guard.proxy_session_id) + }; + debug!( + "HLS origin account rebind started: proxy_session={} old_account={} reason={stale_status:?}", + safe_proxy_session, + sanitize_sensitive_info(stale_binding.account_name.as_ref()) + ); + app_state.active_provider.clear_provider_reservation(&stale_binding.session_owner).await; + { + let mut session_guard = session.write().await; + if let Some(binding) = session_guard.origin_account_binding.as_mut().filter(|binding| { + binding.account_name == stale_binding.account_name && binding.session_owner == stale_binding.session_owner + }) { + binding.detach(HlsOriginAccountDetachedReason::AccountMissingOrExpired, now_ms); + debug!( + "HLS origin binding detached: proxy_session={} account={} reason={}", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(stale_binding.account_name.as_ref()), + HlsOriginAccountDetachedReason::AccountMissingOrExpired.as_log_reason() + ); + } + session_guard.invalidate_queued_origin_work(); + } + + let Some(provider_handle) = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &fingerprint.addr, + false, + priority, + connection_kind, + Some(&stale_binding.session_owner), + ) + .await + else { + mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "no_account_available").await; + return Err(HlsOriginRuntimeAcquireError::NoAccountAvailable { + reason: hls_no_account_reason_for_binding(Some(stale_binding)), + }); + }; + + let Some(provider_config) = provider_handle.allocation.get_provider_config() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "no_provider_config").await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let Some(fetch_url) = build_hls_origin_fetch_url(input, raw_request_url, session_entry_url, Some(&provider_config)) + else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "invalid_origin_url").await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let Some(new_account_name) = provider_handle.allocation.get_provider_name() else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + mark_hls_origin_rebind_failed(session, stale_binding, now_ms, "missing_account_name").await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + + let new_binding = HlsOriginAccountBinding::rebound( + Arc::clone(&input.name), + new_account_name, + stale_binding.session_owner.clone(), + stale_binding.generation.saturating_add(1), + now_ms, + ); + { + let mut session_guard = session.write().await; + session_guard.replace_origin_account_binding(Some(new_binding.clone())); + session_guard.origin_account_rebind.mark_success(); + } + debug!( + "HLS origin account rebound: old_account={} new_account={}", + sanitize_sensitive_info(stale_binding.account_name.as_ref()), + sanitize_sensitive_info(new_binding.account_name.as_ref()) + ); + + Ok(PreparedHlsOriginRuntime { + url_failover_provider: hls_url_failover_provider_for_origin_context( + input, + raw_request_url, + session_entry_url, + &fetch_url, + ), + runtime_provider_config: Some(provider_config), + fetch_url, + origin_account_binding_to_store: None, + preacquired_origin_account_handle: Some(provider_handle), + }) +} + +pub(super) async fn mark_hls_origin_rebind_failed( + session: &HlsSessionHandle, + stale_binding: &HlsOriginAccountBinding, + now_ms: u64, + reason: &str, +) { + let mut session_guard = session.write().await; + session_guard.origin_account_rebind.mark_failed(now_ms); + debug!( + "HLS origin account rebind failed: proxy_session={} old_account={} reason={reason} retry_after_ms=2000", + safe_proxy_session_id(&session_guard.proxy_session_id), + sanitize_sensitive_info(stale_binding.account_name.as_ref()) + ); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_hls_cache_user_session( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + session_token: &str, + virtual_id: u32, + request_url: &str, + input: &ConfigInput, + connection_permission: UserConnectionPermission, + connection_kind: Option, +) -> String { + app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token, + virtual_id, + provider: input.name.as_ref(), + stream_url: request_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind, + socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), + }) + .await +} + +pub(super) fn hls_entry_origin_connection_kind( + connection_permission: UserConnectionPermission, + connection_kind: Option, +) -> Option { + match connection_permission { + UserConnectionPermission::Allowed | UserConnectionPermission::GracePeriod => connection_kind, + UserConnectionPermission::Exhausted => None, + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn create_hls_cache_entry_master_playlist_response( + app_state: &Arc, + fingerprint: &Fingerprint, + user: &ProxyUserCredentials, + origin_source: HlsOriginSource, + virtual_id: u32, + existing_user_session: Option<&UserSession>, + known_bitrate_bps: Option, + session_token_hint: Option<&str>, + request_url: &str, + input: &ConfigInput, + connection_permission: UserConnectionPermission, + connection_kind: Option, + server_path: Option<&str>, +) -> axum::response::Response { + let item_bandwidth = HlsMasterBandwidth::new(known_bitrate_bps); + let database_bitrate_bps = if item_bandwidth.is_unknown() { + match load_input_live_bitrate_bps(&app_state.app_config, input, &origin_source.stream_ref).await { + Ok(known_bitrate_bps) => known_bitrate_bps, + Err(err) => { + warn!("HLS entry live bitrate lookup failed; using fallback: input_id={} error={err}", input.id); + None + } + } + } else { + None + }; + let bandwidth = HlsMasterBandwidthSelection::resolve(known_bitrate_bps, database_bitrate_bps); + let known_bitrate_bps = bandwidth.known_bitrate_bps(); + + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let family_key = HlsPlaybackFamilyKey::new(user.username.clone(), fingerprint.key.clone()); + let now_ms = current_time_millis(); + let origin_connection_kind = hls_entry_origin_connection_kind(connection_permission, connection_kind); + let access_lease_id = new_hls_access_lease_id(); + let existing_token = existing_user_session.map(|session| session.token.as_str()).or(session_token_hint); + let session_token = create_hls_cache_user_session_token( + fingerprint, + &user.username, + virtual_id, + existing_token, + origin_source.archive_reference, + ); + let session_token = prepare_hls_cache_user_session( + app_state, + fingerprint, + user, + &session_token, + virtual_id, + request_url, + input, + connection_permission, + origin_connection_kind, + ) + .await; + let mut lease = HlsAccessLease::pending( + access_lease_id.clone(), + family_key, + proxy_session_id.clone(), + user.username.clone(), + session_token.clone(), + origin_source.input_id, + origin_source.stream_ref.clone(), + virtual_id, + now_ms, + hls_pending_bootstrap_window_ms(app_state), + ) + .with_known_bitrate_bps(known_bitrate_bps) + .with_archive_playback( + origin_source.archive_reference, + origin_source.archive_reference.map(|_| request_url.to_string()), + ); + if let Some(connection_kind) = origin_connection_kind { + lease = lease.with_origin_acquire_policy(connection_kind, connection_priority_for_kind(user, connection_kind)); + } else { + lease.state = HlsAccessLeaseState::Denied; + } + app_state.hls_proxy.prepare_access_lease(lease).await; + debug!( + "HLS access lease prepared: lease={} session={} proxy_session={} user_session={} action=created reason=new-playback", + safe_hls_access_lease_id(&access_lease_id), + safe_session_key(&session_key), + safe_proxy_session_id(&proxy_session_id), + safe_user_session_token(&session_token) + ); + let response = + hls_entry_master_playlist_response(&proxy_session_id, &access_lease_id, bandwidth.bandwidth(), server_path); + app_state.hls_proxy.startup_observability().record_entry_master_response( + access_lease_id.clone(), + HlsLogIdentity::new(&session_key, &proxy_session_id), + current_time_millis(), + ); + debug!( + "HLS master playlist response: {}", + HlsMasterPlaylistResponseDiagnostic { + lease: safe_hls_access_lease_id(&access_lease_id), + session: safe_session_key(&session_key), + proxy_session: safe_proxy_session_id(&proxy_session_id), + user_session: safe_user_session_token(&session_token), + virtual_id, + bandwidth_bps: bandwidth.bandwidth().advertised_bps(), + bandwidth_source: bandwidth.source().as_log_value(), + content_length: response.content_length, + } + ); + response.response +} + +pub(super) struct HlsEntryMasterPlaylistResponse { + pub(super) response: axum::response::Response, + pub(super) content_length: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct HlsMasterPlaylistResponseDiagnostic { + pub(super) lease: String, + pub(super) session: String, + pub(super) proxy_session: String, + pub(super) user_session: String, + pub(super) virtual_id: u32, + pub(super) bandwidth_bps: u32, + pub(super) bandwidth_source: &'static str, + pub(super) content_length: usize, +} + +impl std::fmt::Display for HlsMasterPlaylistResponseDiagnostic { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "lease={} session={} proxy_session={} user_session={} virtual_id={} bandwidth_bps={} bandwidth_source={} status=200 content_length={}", + self.lease, + self.session, + self.proxy_session, + self.user_session, + self.virtual_id, + self.bandwidth_bps, + self.bandwidth_source, + self.content_length + ) + } +} + +pub(super) async fn hls_segment_request_requires_origin_work( + session: &HlsSessionHandle, + segment_file: &HlsSegmentFile, +) -> bool { + let session = session.read().await; + let Some(entry) = session.segments.get(&segment_file.proxy_seq) else { + return false; + }; + if entry.proxy_file_ext != segment_file.extension { + return false; + } + matches!(entry.status, SegmentCacheStatus::Discovered | SegmentCacheStatus::Queued { .. }) + && entry.origin_fetch_ref.is_some() +} + +pub(super) async fn hls_origin_binding_needs_reacquire(session: &HlsSessionHandle) -> bool { + let session = session.read().await; + session.origin_account_binding.as_ref().is_some_and(HlsOriginAccountBinding::is_detached) +} + +pub(super) fn hls_transient_origin_binding_requires_runtime_prepare( + hls_ctx: &HlsCtx, + binding: &HlsOriginAccountBinding, +) -> bool { + binding.is_detached() + || (binding.is_active() + && matches!( + hls_origin_account_status(hls_ctx, binding), + HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired + )) +} + +pub(super) async fn prepare_hls_origin_binding_for_authorized_resource_work( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + req_headers: &HeaderMap, + work_kind: HlsOriginWorkKind, + now_ms: u64, +) -> Result, HlsOriginRuntimeAcquireError> { + if !hls_origin_binding_needs_reacquire(session).await { + return Ok(None); + } + if session.read().await.activity.active_origin_work_count > 0 { + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + } + let request_context = resolve_hls_playback_manifest_request_context(app_state, access_context, req_headers) + .await + .map_err(HlsOriginRuntimeAcquireError::Fatal)?; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let origin_policy = hls_effective_origin_acquire_policy(session).await; + let prepared_origin = prepare_hls_origin_runtime( + app_state, + session, + &request_context.input, + &request_context.hls_url, + request_context.session_entry_url.as_str(), + &proxy_session_id, + fingerprint, + origin_policy.connection_kind, + origin_policy.priority, + work_kind, + HlsOriginWorkClass::Demand, + now_ms, + ) + .await?; + if let Some(binding) = prepared_origin.origin_account_binding_to_store { + session.write().await.replace_origin_account_binding(Some(binding)); + } + Ok(prepared_origin.preacquired_origin_account_handle) +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn prepare_hls_transient_origin_io_for_authorized_resource_work( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + req_headers: &HeaderMap, + now_ms: u64, +) -> Result, HlsOriginRuntimeAcquireError> { + let hls_ctx = app_state.hls_ctx(); + let existing_binding = session.read().await.origin_account_binding.clone(); + let origin_policy = hls_effective_origin_acquire_policy(session).await; + let reservation_ttl_secs = hls_origin_account_reservation_ttl_secs_for_session(session).await; + if let Some(binding) = existing_binding.as_ref().filter(|binding| binding.is_active()) { + match hls_origin_account_status(&hls_ctx, binding) { + HlsOriginAccountStatus::Known => { + let origin_io = HlsOriginIoContext { + ctx: hls_ctx.clone(), + client_addr: fingerprint.addr, + allow_grace: HlsOriginWorkClass::Demand.allows_grace(), + priority: origin_policy.priority, + connection_kind: origin_policy.connection_kind, + reservation_ttl_secs, + preacquired_provider_handle: None, + started_generation: None, + }; + let started_generation = session.write().await.start_origin_work(); + if let Ok(lease_guard) = begin_hls_origin_account_io_bounded( + &origin_io, + session, + binding, + hls_object_body_deadline(app_state.hls_proxy.segment_fetch_policy().origin_segment_timeout_ms), + ) + .await + { + return Ok(Some(HlsTransientOriginIoGuard::new( + Arc::clone(session), + origin_io, + lease_guard, + started_generation, + ))); + } + session.write().await.finish_origin_work(started_generation); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + } + HlsOriginAccountStatus::Missing | HlsOriginAccountStatus::Expired => {} + } + } + + if !existing_binding + .as_ref() + .is_some_and(|binding| hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, binding)) + { + return Ok(None); + } + if session.read().await.activity.active_origin_work_count > 0 { + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + } + let request_context = resolve_hls_playback_manifest_request_context(app_state, access_context, req_headers) + .await + .map_err(HlsOriginRuntimeAcquireError::Fatal)?; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let prepared_origin = prepare_hls_origin_runtime( + app_state, + session, + &request_context.input, + &request_context.hls_url, + request_context.session_entry_url.as_str(), + &proxy_session_id, + fingerprint, + origin_policy.connection_kind, + origin_policy.priority, + HlsOriginWorkKind::Resource, + HlsOriginWorkClass::Demand, + now_ms, + ) + .await?; + if let Some(binding) = prepared_origin.origin_account_binding_to_store { + session.write().await.replace_origin_account_binding(Some(binding)); + } + let Some(provider_handle) = prepared_origin.preacquired_origin_account_handle else { + return Ok(None); + }; + let Some(binding) = session.read().await.origin_account_binding.clone().filter(HlsOriginAccountBinding::is_active) + else { + app_state.connection_manager.release_provider_handle(Some(provider_handle)).await; + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + let origin_io = HlsOriginIoContext { + ctx: hls_ctx, + client_addr: fingerprint.addr, + allow_grace: HlsOriginWorkClass::Demand.allows_grace(), + priority: origin_policy.priority, + connection_kind: origin_policy.connection_kind, + reservation_ttl_secs, + preacquired_provider_handle: None, + started_generation: None, + } + .with_preacquired_provider_handle(provider_handle); + let started_generation = session.write().await.start_origin_work(); + let Ok(lease_guard) = begin_hls_origin_account_io_bounded( + &origin_io, + session, + &binding, + hls_object_body_deadline(app_state.hls_proxy.segment_fetch_policy().origin_segment_timeout_ms), + ) + .await + else { + session.write().await.finish_origin_work(started_generation); + return Err(HlsOriginRuntimeAcquireError::Fatal(StatusCode::SERVICE_UNAVAILABLE)); + }; + Ok(Some(HlsTransientOriginIoGuard::new(Arc::clone(session), origin_io, lease_guard, started_generation))) +} diff --git a/backend/app/src/api/endpoints/hls_api/tests.rs b/backend/app/src/api/endpoints/hls_api/tests.rs new file mode 100644 index 000000000..ccb226964 --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/tests.rs @@ -0,0 +1,12073 @@ +use super::{ + super::hls_terminal_response::{ + hls_manifest_terminal_preflight, hls_terminal_endpoint_action, hls_terminal_failed_closed_response, + HlsManifestTerminalPreflight, HlsTerminalEndpointAction, + }, + build_hls_manifest_request_headers, extract_hls_provider_session_headers, hls_api_register, + hls_availability_reevaluation_registration_failure_response, hls_canonical_owner_registration, + hls_temporary_resource_unavailable_response, m3u_archive_epg_reference_ts, + m3u_catchup_epg_reference_from_session_token, resolve_leaked_hls_relative_origin, HlsCanonicalOwnerRegistration, + HlsCanonicalOwnerRegistrationFailure, HlsCanonicalOwnerRegistrationKind, MAX_HLS_MANIFEST_BYTES, +}; +use crate::{ + api::model::{ + begin_hls_origin_account_io, build_hls_custom_video_manifest_body, build_proxy_session_id, + build_terminal_tail_plan, build_transient_resource_id, commit_terminal_tail_if_lease_reserve_requires_cutover, + finish_hls_origin_account_io, prepare_terminal_base_evidence, prepared_terminal_bundle_key, + snapshot_terminal_media_asset, trigger_origin_refresh_sync, ActiveProviderManager, ActiveUserManager, AppState, + CacheAccessState, CancelTokens, ConnectionKind, ConnectionManager, CreateUserSessionParams, + CustomVideoStreamType, EventManager, HlsAcceptanceEpisodeTiming, HlsAcceptanceEpisodeTimingInput, + HlsAccessAdmissionMode, HlsAccessContext, HlsAccessLease, HlsAccessLeaseId, HlsAccessLeaseState, + HlsAccessLeaseTiming, HlsAccessLeaseTouch, HlsAccessLeaseValidationError, + HlsAvailabilityReevaluationFinishReason, HlsAvailabilityReevaluationMode, + HlsAvailabilityReevaluationRegistration, HlsBandwidthPersistenceState, HlsEffectiveOriginAcquirePolicy, + HlsFreshManifestRequiredReason, HlsLeaseManifestSegment, HlsLeaseManifestSnapshot, HlsLeasePlaybackMode, + HlsLifecycleEvent, HlsLifecycleEventKey, HlsManifestAcceptanceDirective, + HlsManifestAcceptanceEvaluationOutcome, HlsManifestAcceptanceExhaustionReason, HlsManifestAcceptanceTrigger, + HlsManifestCommitIdentity, HlsManifestCommitRequirement, HlsManifestDeliveryMode, HlsMapSignature, + HlsMediaContainer, HlsObservedRecoveryLatency, HlsOperationTimeoutMs, HlsOriginAccountBinding, + HlsOriginAccountBindingMode, HlsOriginAccountDetachedReason, HlsOriginIoContext, HlsOriginPathCondition, + HlsOriginSource, HlsOriginSourceKind, HlsPlaybackFamilyKey, HlsPreparedTerminalBundleState, HlsProxyManager, + HlsPublishedTransientResourceIds, HlsRecoveryEtaMs, HlsRecoveryTimingPolicy, HlsRecoveryWorkload, + HlsRuntimeCustomTailAssetIdentity, HlsRuntimeCustomTailReason, HlsSegmentFile, HlsSession, HlsSessionHandle, + HlsSessionKey, HlsSessionMode, HlsSessionStoreOutcome, HlsTerminalAssetIdentity, HlsTerminalBaseMediaState, + HlsTerminalBaseProtection, HlsTerminalBaseSegmentAvailability, HlsTerminalFailedClosedReason, + HlsTerminalMediaAsset, HlsTerminalMediaPreparationState, HlsTerminalResolution, HlsTerminalSegmentPath, + HlsTerminalTailBuildInput, HlsTerminalTailCompatibility, HlsTerminalTailGeneration, HlsTerminalTailPlan, + HlsTerminalTailProtection, HlsTransitionMarginMs, LiveHlsOriginEntry, ManualPlaylistUpdateRequest, + MapCacheStatus, MapEntry, MetadataUpdateManager, OriginMapKey, OriginRefreshRequest, OriginSegmentFetchRef, + OriginSegmentKey, PlaybackLifecycle, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, + ProviderConfigConnection, ProxyMapId, ProxySessionId, RenderedManifest, RetryPolicy, SegmentCacheKey, + SegmentCacheStatus, SegmentEntry, SegmentFetchPriority, SharedStreamManager, TransientObjectCacheKey, + TransientObjectCacheStatus, TransientResourceId, TransientResourceKind, TransientResourceRef, + TransportStreamBuffer, UpdateGuard, UserSession, HLS_TERMINAL_TAIL_SEGMENT_COUNT, + }, + auth::Fingerprint, + model::{ + ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, ConfigTarget, + CustomStreamResponse, HlsCacheConfig, ProcessTargets, ProxyUserCredentials, ReverseProxyConfig, + ReverseProxyDisabledHeaderConfig, SourcesConfig, StripConfig, TargetUser, + }, + processing::parser::hls::{ + origin_manifest::{parse_origin_media_manifest, OriginManifestParseOutcome}, + rewrite_hls, RewriteHlsProps, + }, + repository::GeoIp, +}; +use aes::{ + cipher::{Block, BlockEncrypt, KeyInit}, + Aes128, +}; +use arc_swap::{ArcSwap, ArcSwapOption}; +use axum::{ + body::Body, + extract::ConnectInfo, + http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode}, + response::IntoResponse, +}; +use http_body_util::BodyExt; +use shared::{ + model::{ + provider_saturation::build_group_lookup, ConfigPaths, ConfigProviderDto, ConfigTargetDto, ConfigTargetOptions, + ConfigTargetShareLiveStreams, HlsCacheConfigDto, HlsManifestRecoveryBurstConfigDto, + HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripConfigDto, HlsStripMode, InputType, + M3uPlaylistItem, M3uTargetOutputDto, PlaylistItem, PlaylistItemHeader, PlaylistItemType, + ProviderUrlSelectionPolicy, ReverseProxyConfigDto, StreamConfigDto, StreamProperties, TargetOutputDto, + UserConnectionPermission, VirtualId, XtreamCluster, XtreamTargetOutputDto, + }, + utils::Internable, +}; +use std::{ + collections::HashMap, + fmt::Write as _, + net::SocketAddr, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::{mpsc, RwLock}, +}; +use tower::ServiceExt; + +fn path_has_extension(path: &str, extension: &str) -> bool { + std::path::Path::new(path).extension().is_some_and(|actual| actual.eq_ignore_ascii_case(extension)) +} + +#[test] +fn archive_epg_reference_supports_query_and_path_formats() { + assert_eq!( + m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?utc=1700000000&lutc=1700003600"), + Some(1_700_000_000) + ); + assert_eq!( + m3u_archive_epg_reference_ts("http://provider/live/archive-1700003600-1700007200.m3u8"), + Some(1_700_003_600) + ); + assert_eq!(m3u_archive_epg_reference_ts("http://provider/live/timeshift_abs-1700007200.ts"), Some(1_700_007_200)); + assert_eq!( + m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?start=1700000000&end=1700003600"), + Some(1_700_000_000) + ); +} + +#[test] +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 date_tree_path_recovers_bittv_archive_epg_reference() { + assert_eq!(super::epg_reference_ts_from_date_tree_path("2026/07/24/14/13/38-06800.ts"), Some(1_784_902_418)); + assert_eq!( + super::epg_reference_ts_from_date_tree_path("dvr-2026/07/24/14/13/38-06800.ts"), + super::epg_reference_ts_from_date_tree_path("2026/07/24/14/13/38-06800.ts") + ); + assert!(super::looks_like_archive_media_path("2026/07/24/14/13/38-06800.ts")); +} + +#[test] +fn archive_media_path_does_not_accept_plain_202_prefixed_segments() { + assert!(!super::looks_like_archive_media_path("2026.ts")); + assert!(!super::looks_like_archive_media_path("202_media.ts")); +} + +#[test] +fn session_token_recovers_archive_epg_reference_when_media_url_lost_markers() { + assert_eq!( + m3u_catchup_epg_reference_from_session_token("m3u-catchup|user|42|archive|1717200000|3600"), + Some(1_717_200_000) + ); + assert_eq!(m3u_catchup_epg_reference_from_session_token("m3u-catchup|user|42|live"), None); +} + +#[test] +fn append_catchup_session_hint_keeps_m3u_catchup_token_without_shared_hls_cache() { + let fingerprint = test_fingerprint(); + let hint = "m3u-catchup|fp|alice|42|deadbeef"; + let token = super::hls_entry_user_session_token(&fingerprint, "alice", 42, Some(hint), Some(1_717_200_000)); + assert_eq!(token, hint); + assert!(super::is_m3u_catchup_session_token(&token)); + + let from_archive = super::hls_entry_user_session_token(&fingerprint, "alice", 42, None, Some(1_717_200_000)); + assert!(from_archive.contains("|archive|1717200000|0")); + assert!(super::is_m3u_catchup_session_token(&from_archive)); + assert!(super::is_m3u_catchup_session_token("m3u-catchup|fp|alice|42|timeshift_abs|1717200000|0")); +} + +#[test] +fn leaked_dvr_relative_joins_against_media_playlist_and_dvr_session_root() { + assert_eq!( + resolve_leaked_hls_relative_origin( + "http://cdn.example/big/aa_1/media.m3u8", + "dvr-2026/07/26/15/30/59-06000.ts", + Some("token=abc"), + ), + Some("http://cdn.example/big/aa_1/dvr-2026/07/26/15/30/59-06000.ts?token=abc".to_string()) + ); + assert_eq!( + resolve_leaked_hls_relative_origin( + "http://cdn.example/big/aa_1/dvr-2026/07/26/15/30/59-06000.ts?token=old", + "dvr-2026/07/26/15/31/05-06000.ts", + Some("token=new"), + ), + Some("http://cdn.example/big/aa_1/dvr-2026/07/26/15/31/05-06000.ts?token=new".to_string()) + ); + assert_eq!( + resolve_leaked_hls_relative_origin("http://cdn.example/big/aa_1/media.m3u8", "segment001.ts", None,), + None + ); + assert_eq!( + resolve_leaked_hls_relative_origin( + "http://cdn.example/big/aa_1/media.m3u8", + "dvr-2026/07/26/15/30/../59-06000.ts", + None, + ), + None + ); + assert_eq!(resolve_leaked_hls_relative_origin("http://cdn.example/live.m3u8", "./dvr-2026/a.ts", None), None); +} + +#[test] +fn archive_epg_reference_supports_contextual_start_aliases() { + assert_eq!( + m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?offset=-3600&utcstart=1717200000"), + Some(1_717_200_000) + ); + assert_eq!( + m3u_archive_epg_reference_ts("http://provider/live/42.m3u8?timestamp=1717200000&offset=120"), + Some(1_717_200_000) + ); +} + +#[test] +fn canonical_manifest_joins_authoritative_owner_and_fails_closed_without_one() { + assert_eq!( + hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::Scheduled), + HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::Scheduled) + ); + assert_eq!( + hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::AlreadyOwned), + HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) + ); + assert_eq!( + hls_canonical_owner_registration(HlsAvailabilityReevaluationRegistration::Superseded), + HlsCanonicalOwnerRegistration::Join(HlsCanonicalOwnerRegistrationKind::AlreadyOwned) + ); + for (registration, failure) in [ + ( + HlsAvailabilityReevaluationRegistration::CapacityExceeded, + HlsCanonicalOwnerRegistrationFailure::CapacityExceeded, + ), + ( + HlsAvailabilityReevaluationRegistration::RuntimeUnavailable, + HlsCanonicalOwnerRegistrationFailure::RuntimeUnavailable, + ), + ] { + assert_eq!(hls_canonical_owner_registration(registration), HlsCanonicalOwnerRegistration::FailClosed(failure)); + let response = hls_availability_reevaluation_registration_failure_response(failure); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().contains_key(header::RETRY_AFTER)); + } +} + +struct CanonicalOwnerHandoffFixture { + app_state: Arc, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + leases: Vec<(HlsAccessLeaseId, u64)>, + strip: StripConfig, +} + +impl CanonicalOwnerHandoffFixture { + async fn new(lease_ids: &[&str]) -> Self { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "owner-handoff"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let issued_at_ms = super::current_time_millis(); + let mut leases = Vec::with_capacity(lease_ids.len()); + for lease_id in lease_ids { + let lease_id = HlsAccessLeaseId((*lease_id).to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + format!("{}-session", lease_id.0), + 1, + "owner-handoff".to_string(), + 12345, + issued_at_ms, + 60_000, + )) + .await; + leases.push((lease_id, issued_at_ms)); + } + Self { + app_state, + session, + proxy_session_id, + leases, + strip: StripConfig { mode: HlsStripMode::Segments, value: 0 }, + } + } + + async fn safe_session(&self) -> String { + let session = self.session.read().await; + super::safe_session_key(&session.key) + } + + fn handoff_context( + &self, + lease_index: usize, + safe_session: String, + request_deadline_ms: u64, + ) -> super::HlsCanonicalOwnerHandoffContext<'_> { + let (lease_id, issued_at_ms) = &self.leases[lease_index]; + super::HlsCanonicalOwnerHandoffContext { + app_state: &self.app_state, + proxy_session_id: &self.proxy_session_id, + access_lease_id: lease_id, + expected_lease_issued_at_ms: Some(*issued_at_ms), + strip: &self.strip, + server_path: None, + manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, + manifest_boundary_rendered_at_ms: 0, + bandwidth_learning: super::HlsRuntimeBandwidthLearningContext::Disabled, + request_deadline_ms, + safe_session, + } + } +} + +async fn publish_owner_handoff_test_manifest(session: &HlsSessionHandle) { + let now_ms = super::current_time_millis(); + let manifest = normal_manifest( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ + #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", + ); + let mut session = session.write().await; + session.apply_origin_manifest(&manifest).expect("owner handoff manifest maps"); + for segment in session.segments.values_mut() { + segment.status = SegmentCacheStatus::Ready { content_length: 1_000, ready_at_ms: now_ms }; + } + session.advance_media_readiness_generation(); + session.render_and_store_manifest(now_ms).expect("owner handoff manifest renders"); + session.mark_authorized_media_access(now_ms); +} + +#[tokio::test] +async fn scheduled_owner_does_not_return_transient_503() { + let fixture = CanonicalOwnerHandoffFixture::new(&["scheduled-lease"]).await; + let owner_key = fixture + .app_state + .hls_proxy + .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) + .await + .expect("owner key"); + let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let completed = Arc::new(tokio::sync::Notify::new()); + let task_started = Arc::clone(&started); + let task_release = Arc::clone(&release); + let task_completed = Arc::clone(&completed); + let task_session = Arc::clone(&fixture.session); + let task_app_state = Arc::clone(&fixture.app_state); + let task_proxy_session_id = fixture.proxy_session_id.clone(); + let task_owner_key = owner_key.clone(); + assert_eq!( + coordinator.register( + owner_key, + HlsAvailabilityReevaluationMode::RecoveryPressure, + move |ownership| async move { + task_started.notify_one(); + task_release.notified().await; + publish_owner_handoff_test_manifest(&task_session).await; + task_app_state.hls_proxy.notify_session_evidence_changed(&task_proxy_session_id); + let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); + task_completed.notify_one(); + }, + ), + HlsAvailabilityReevaluationRegistration::Scheduled + ); + started.notified().await; + let deadline_ms = super::current_time_millis().saturating_add(60_000); + let safe_session = fixture.safe_session().await; + let mut response = Box::pin(super::join_hls_canonical_manifest_owner( + fixture.handoff_context(0, safe_session, deadline_ms), + HlsCanonicalOwnerRegistrationKind::Scheduled, + )); + + assert!(matches!(futures::poll!(response.as_mut()), std::task::Poll::Pending)); + release.notify_one(); + + let response = response.await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::RETRY_AFTER)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("live manifest utf8"); + assert!(body.contains("/scheduled-lease/")); + completed.notified().await; +} + +#[tokio::test] +async fn already_owned_work_is_joined_without_duplicate_refresh() { + let fixture = CanonicalOwnerHandoffFixture::new(&["first-lease", "second-lease"]).await; + let owner_key = fixture + .app_state + .hls_proxy + .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) + .await + .expect("owner key"); + let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); + let release = Arc::new(tokio::sync::Notify::new()); + let completed = Arc::new(tokio::sync::Notify::new()); + let task_release = Arc::clone(&release); + let task_completed = Arc::clone(&completed); + let task_session = Arc::clone(&fixture.session); + let task_app_state = Arc::clone(&fixture.app_state); + let task_proxy_session_id = fixture.proxy_session_id.clone(); + let task_owner_key = owner_key.clone(); + assert_eq!( + coordinator.register( + owner_key.clone(), + HlsAvailabilityReevaluationMode::RecoveryPressure, + move |ownership| async move { + task_release.notified().await; + publish_owner_handoff_test_manifest(&task_session).await; + task_app_state.hls_proxy.notify_session_evidence_changed(&task_proxy_session_id); + let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); + task_completed.notify_one(); + }, + ), + HlsAvailabilityReevaluationRegistration::Scheduled + ); + let duplicate_owner_runs = Arc::new(AtomicUsize::new(0)); + let task_duplicate_owner_runs = Arc::clone(&duplicate_owner_runs); + assert_eq!( + coordinator.register(owner_key, HlsAvailabilityReevaluationMode::RecoveryPressure, move |_| async move { + task_duplicate_owner_runs.fetch_add(1, Ordering::SeqCst); + },), + HlsAvailabilityReevaluationRegistration::AlreadyOwned + ); + let deadline_ms = super::current_time_millis().saturating_add(60_000); + let mut first = Box::pin(super::join_hls_canonical_manifest_owner( + fixture.handoff_context(0, fixture.safe_session().await, deadline_ms), + HlsCanonicalOwnerRegistrationKind::AlreadyOwned, + )); + let mut second = Box::pin(super::join_hls_canonical_manifest_owner( + fixture.handoff_context(1, fixture.safe_session().await, deadline_ms), + HlsCanonicalOwnerRegistrationKind::AlreadyOwned, + )); + assert!(matches!(futures::poll!(first.as_mut()), std::task::Poll::Pending)); + assert!(matches!(futures::poll!(second.as_mut()), std::task::Poll::Pending)); + + release.notify_one(); + let (first, second) = tokio::join!(first, second); + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + let first_body = String::from_utf8(response_body(first).await.to_vec()).expect("first manifest utf8"); + let second_body = String::from_utf8(response_body(second).await.to_vec()).expect("second manifest utf8"); + assert!(first_body.contains("/first-lease/")); + assert!(!first_body.contains("/second-lease/")); + assert!(second_body.contains("/second-lease/")); + assert!(!second_body.contains("/first-lease/")); + assert_eq!(duplicate_owner_runs.load(Ordering::SeqCst), 0); + completed.notified().await; + tokio::task::yield_now().await; + assert_eq!(coordinator.owner_count(), 0); +} + +#[tokio::test] +async fn canonical_owner_join_preserves_bounded_deadline_failure() { + let fixture = CanonicalOwnerHandoffFixture::new(&["deadline-lease"]).await; + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.leases[0].0, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("pending deadline lease"); + assert_eq!( + super::hls_canonical_owner_request_deadline_ms(&lease, Duration::ZERO, super::current_time_millis(),), + lease.pending_deadline_ms().expect("pending lease deadline") + ); + let owner_key = fixture + .app_state + .hls_proxy + .availability_reevaluation_owner_key(&fixture.session, &fixture.proxy_session_id) + .await + .expect("owner key"); + let coordinator = fixture.app_state.hls_proxy.availability_reevaluations(); + assert_eq!( + coordinator.register(owner_key, HlsAvailabilityReevaluationMode::RecoveryPressure, |ownership| async move { + ownership.cancelled().await; + },), + HlsAvailabilityReevaluationRegistration::Scheduled + ); + let response = super::join_hls_canonical_manifest_owner( + fixture.handoff_context(0, fixture.safe_session().await, super::current_time_millis().saturating_sub(1)), + HlsCanonicalOwnerRegistrationKind::Scheduled, + ) + .await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(!response.headers().contains_key(header::RETRY_AFTER)); + coordinator.cancel_session(&fixture.proxy_session_id); +} + +async fn terminal_generation_for_lease( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, +) -> HlsTerminalTailGeneration { + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) + .await + .expect("terminal lease remains stored"); + let HlsLeasePlaybackMode::TerminalTail(plan) = lease.playback_mode else { + panic!("lease remains terminal"); + }; + plan.generation +} + +#[tokio::test] +async fn completed_owner_resolves_new_lease_standalone_without_reusing_old_terminal() { + let temp_dir = tempfile::tempdir().expect("owner handoff cache tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + enable_channel_unavailable_custom_response(&app_state); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"old-terminal-base").await; + let old_lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &old_lease_id.0, 123).await; + let proxy_session_id = ProxySessionId(proxy_session_id); + let now_ms = super::current_time_millis(); + let old_generation = terminal_generation_for_lease(&app_state, &proxy_session_id, &old_lease_id).await; + let new_lease_id = HlsAccessLeaseId("new-owner-handoff-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + new_lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + "new-owner-handoff-session".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + 60_000, + )) + .await; + let session = + app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.expect("shared session"); + let owner_key = + app_state.hls_proxy.availability_reevaluation_owner_key(&session, &proxy_session_id).await.expect("owner key"); + let release = Arc::new(tokio::sync::Notify::new()); + let task_release = Arc::clone(&release); + let task_owner_key = owner_key.clone(); + let coordinator = app_state.hls_proxy.availability_reevaluations(); + assert_eq!( + coordinator.register( + owner_key, + HlsAvailabilityReevaluationMode::RecoveryPressure, + move |ownership| async move { + task_release.notified().await; + let _ = ownership.finish_cycle(&task_owner_key, HlsAvailabilityReevaluationFinishReason::Evaluated); + }, + ), + HlsAvailabilityReevaluationRegistration::Scheduled + ); + let safe_session = { + let session = session.read().await; + super::safe_session_key(&session.key) + }; + let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; + let mut response = Box::pin(super::join_hls_canonical_manifest_owner( + super::HlsCanonicalOwnerHandoffContext { + app_state: &app_state, + proxy_session_id: &proxy_session_id, + access_lease_id: &new_lease_id, + expected_lease_issued_at_ms: Some(now_ms), + strip: &strip, + server_path: None, + manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, + manifest_boundary_rendered_at_ms: 0, + bandwidth_learning: super::HlsRuntimeBandwidthLearningContext::Disabled, + request_deadline_ms: now_ms.saturating_add(60_000), + safe_session, + }, + HlsCanonicalOwnerRegistrationKind::Scheduled, + )); + assert!(matches!(futures::poll!(response.as_mut()), std::task::Poll::Pending)); + + release.notify_one(); + let response = response.await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + assert!(!response.headers().contains_key(header::RETRY_AFTER)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone manifest utf8"); + assert!(body.contains("#EXT-X-ENDLIST")); + assert!(!body.contains("/hls/shared/live/")); + assert_eq!( + terminal_generation_for_lease(&app_state, &proxy_session_id, &old_lease_id).await, + old_generation, + "new lease fallback cannot reactivate old terminal lease" + ); +} + +#[test] +fn hls_cache_session_tokens_separate_live_and_archive_playback() { + let fingerprint = test_fingerprint(); + let live = super::create_hls_cache_user_session_token(&fingerprint, "user", 31, None, None); + let archive = super::create_hls_cache_user_session_token(&fingerprint, "user", 31, None, Some(1_784_898_000)); + + assert!(!super::is_m3u_catchup_session_token(&live)); + assert!(super::is_m3u_catchup_session_token(&archive)); + assert_ne!(live, archive); +} + +#[test] +fn hls_cache_session_token_preserves_existing_m3u_catchup_identity() { + let fingerprint = test_fingerprint(); + let existing = "m3u-catchup|fp|user|31|archive|1784898000|3600"; + let token = + super::create_hls_cache_user_session_token(&fingerprint, "user", 31, Some(existing), Some(1_784_898_000)); + + assert!(token.starts_with(existing)); + assert!(token.contains("|hls-cache|")); +} + +#[test] +fn hls_terminal_commit_endpoint_resolution_mapping_is_exhaustive() { + assert_eq!(hls_terminal_endpoint_action(HlsTerminalResolution::LiveAllowed), HlsTerminalEndpointAction::ServeLive); + assert_eq!( + hls_terminal_endpoint_action(HlsTerminalResolution::Committed), + HlsTerminalEndpointAction::ReloadTerminal + ); + assert_eq!(hls_terminal_endpoint_action(HlsTerminalResolution::Reevaluate), HlsTerminalEndpointAction::Reevaluate); + assert_eq!( + hls_terminal_endpoint_action(HlsTerminalResolution::Pending { retry_after_ms: 250 }), + HlsTerminalEndpointAction::RetryAfter { retry_after_ms: 250 } + ); + + for reason in [ + HlsTerminalFailedClosedReason::LeaseStateUnavailable, + HlsTerminalFailedClosedReason::BundleNotReadyWithoutOwner, + HlsTerminalFailedClosedReason::BundleIncompatible, + HlsTerminalFailedClosedReason::SafeCommitDeadlineElapsed, + HlsTerminalFailedClosedReason::RetryCapacityExceeded, + HlsTerminalFailedClosedReason::RetryAttemptsExhausted, + HlsTerminalFailedClosedReason::RuntimeUnavailable, + ] { + assert_eq!( + hls_terminal_endpoint_action(HlsTerminalResolution::FailedClosed { reason }), + HlsTerminalEndpointAction::FailClosed { reason } + ); + } +} + +#[tokio::test] +async fn hls_manifest_terminal_preflight_distinguishes_bootstrap_refresh_and_invalid_missing_snapshot() { + let app_state = test_app_state(); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "preflight-stream"), &app_state.get_encrypt_secret(), 1_000) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let mut lease = HlsAccessLease::pending( + HlsAccessLeaseId("preflight-lease".to_string()), + HlsPlaybackFamilyKey::new("hls-user", "preflight-client"), + proxy_session_id, + "hls-user".to_string(), + "preflight-user-session".to_string(), + 1, + "preflight-stream".to_string(), + 12345, + 1_000, + 120_000, + ); + + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 2_000).await, + HlsManifestTerminalPreflight::BootstrapPendingLease + ); + + lease.state = HlsAccessLeaseState::Activated; + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 2_000).await, + HlsManifestTerminalPreflight::FailClosed { reason: HlsTerminalFailedClosedReason::LeaseStateUnavailable } + ); + + lease.last_manifest_snapshot = Some(HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, + source_commit_identity: HlsManifestCommitIdentity::new(1_500), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 1, + delivered_at_ms: 1_500, + first_proxy_seq: 0, + last_proxy_seq: 0, + visible_segments: Arc::from([]), + discontinuity_sequence: 0, + target_duration_ms: 4_000, + playlist_duration_ms: 0, + last_visible_media_end_ms: 0, + active_map: None, + active_encryption: None, + container: HlsMediaContainer::MpegTs, + }); + { + let mut session = session.write().await; + session.origin_control.target_duration_snapshot_ms = Some(4_000); + session.origin_control.last_media_progress_at_ms = Some(2_000); + } + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 8_000).await, + HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation + ); + session.write().await.origin_control.last_media_progress_at_ms = Some(7_999); + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 8_000).await, + HlsManifestTerminalPreflight::EvaluateTerminal + ); + + lease.playback_mode = HlsLeasePlaybackMode::Ended; + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 8_000).await, + HlsManifestTerminalPreflight::ServeCommittedPlayback + ); +} + +#[tokio::test] +async fn hls_manifest_terminal_preflight_keeps_capacity_recovery_out_of_sync_terminal_wait() { + let app_state = test_app_state(); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "capacity-preflight"), &app_state.get_encrypt_secret(), 1_000) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + { + let mut session = session.write().await; + let OriginManifestParseOutcome::Normal(manifest) = parse_origin_media_manifest( + "#EXTM3U\n#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:0\n\ + #EXTINF:4.0,\n0.ts\n#EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n", + "http://origin.example/live/index.m3u8", + ) else { + panic!("capacity preflight manifest parses"); + }; + session.apply_origin_manifest(&manifest).expect("capacity preflight timeline applies"); + for segment in session.segments.values_mut() { + segment.status = SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: 1_000 }; + } + session.segments.get_mut(&1).expect("deferred segment").status = + SegmentCacheStatus::CapacityDeferred { priority: SegmentFetchPriority::Prefetch, deferred_at_ms: 2_000 }; + session.origin_control.target_duration_snapshot_ms = Some(8_000); + session.origin_control.last_media_progress_at_ms = Some(1_000); + } + let mut lease = HlsAccessLease::pending( + HlsAccessLeaseId("capacity-preflight-lease".to_string()), + HlsPlaybackFamilyKey::new("capacity-user", "capacity-client"), + proxy_session_id, + "capacity-user".to_string(), + "capacity-user-session".to_string(), + 1, + "capacity-preflight".to_string(), + 1, + 1_000, + 60_000, + ); + lease.state = HlsAccessLeaseState::Activated; + lease.last_manifest_snapshot = Some(HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, + source_commit_identity: HlsManifestCommitIdentity::new(1), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 1, + delivered_at_ms: 1_000, + first_proxy_seq: 0, + last_proxy_seq: 0, + visible_segments: Arc::from([HlsLeaseManifestSegment { + proxy_seq: 0, + duration_ms: 4_000, + uri: "000000.ts".to_string().into(), + discontinuity_before: false, + map_ref_ready: true, + encryption: None, + }]), + discontinuity_sequence: 0, + target_duration_ms: 8_000, + playlist_duration_ms: 4_000, + last_visible_media_end_ms: 4_000, + active_map: None, + active_encryption: None, + container: HlsMediaContainer::MpegTs, + }); + + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 20_000).await, + HlsManifestTerminalPreflight::EvaluateTerminal, + ); + + session.write().await.segments.get_mut(&1).expect("recovered segment").status = + SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: 20_001 }; + assert_eq!( + hls_manifest_terminal_preflight(&session, &lease, 20_001).await, + HlsManifestTerminalPreflight::RefreshBeforeTerminalEvaluation, + ); +} + +#[test] +fn hls_terminal_commit_endpoint_pending_and_failed_closed_headers_are_distinct() { + let pending = hls_temporary_resource_unavailable_response(250); + assert_eq!(pending.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(pending.headers().get(header::RETRY_AFTER).and_then(|value| value.to_str().ok()), Some("1")); + assert!(pending.headers().get(header::LOCATION).is_none()); + + let failed_closed = hls_terminal_failed_closed_response(HlsTerminalFailedClosedReason::RetryAttemptsExhausted); + assert_eq!(failed_closed.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(failed_closed.headers().get(header::RETRY_AFTER).is_none()); + assert!(failed_closed.headers().get(header::LOCATION).is_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")); +} + +fn test_app_config() -> Arc { + let mut hls_user = ProxyUserCredentials::default(); + hls_user.username = "hls-user".to_string(); + hls_user.password = "hls-pass".to_string(); + hls_user.max_connections = 1; + let api_proxy = ApiProxyConfig { + server: vec![ApiProxyServerInfo { + name: "default".to_string(), + protocol: "https".to_string(), + host: "example.test".to_string(), + port: None, + timezone: "UTC".to_string(), + message: String::new(), + path: Some("iptv".to_string()), + }], + user: vec![TargetUser { target: "default".to_string(), credentials: vec![Arc::new(hls_user)] }], + ..Default::default() + }; + Arc::new(AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config { custom_stream_response_enabled: true, ..Default::default() })), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), + hdhomerun: Arc::new(ArcSwapOption::empty()), + api_proxy: Arc::new(ArcSwapOption::from(Some(Arc::new(api_proxy)))), + file_locks: Arc::new(crate::utils::FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::empty()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + }) +} + +fn hls_custom_video_test_user() -> ProxyUserCredentials { + let mut user = ProxyUserCredentials::default(); + user.username = "viewer".to_string(); + user.password = "secret".to_string(); + user +} + +fn test_hls_share_target(hls_enabled: bool) -> ConfigTarget { + ConfigTarget::from(&ConfigTargetDto { + id: 1, + name: "default".to_string(), + options: Some(ConfigTargetOptions { + share_live_streams: ConfigTargetShareLiveStreams { hls: hls_enabled, mpeg_ts: false }, + ..Default::default() + }), + output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto::default())], + ..Default::default() + }) +} + +fn test_m3u_hls_share_target() -> ConfigTarget { + ConfigTarget::from(&ConfigTargetDto { + id: 2, + name: "m3u-target".to_string(), + options: Some(ConfigTargetOptions { + share_live_streams: ConfigTargetShareLiveStreams { hls: true, mpeg_ts: false }, + ..Default::default() + }), + output: vec![TargetOutputDto::M3u(M3uTargetOutputDto::default())], + use_memory_cache: true, + ..Default::default() + }) +} + +fn test_m3u_hls_item(input: &ConfigInput, virtual_id: u32, input_stream_id: &str, url: &str) -> M3uPlaylistItem { + M3uPlaylistItem::from(&PlaylistItem { + header: PlaylistItemHeader { + id: input_stream_id.intern(), + virtual_id: VirtualId::new(virtual_id), + input_name: Arc::clone(&input.name), + url: url.intern(), + item_type: PlaylistItemType::LiveHls, + xtream_cluster: XtreamCluster::Live, + input_stream_id: input_stream_id.intern(), + ..PlaylistItemHeader::default() + }, + }) +} + +fn test_hls_entry_stream_context( + virtual_id: u32, + input_stream_id: &str, + known_bitrate_bps: Option, +) -> super::HlsEntryStreamContext { + super::HlsEntryStreamContext { + identity: super::HlsEntryStreamIdentity::new(virtual_id, input_stream_id).expect("valid input stream identity"), + known_bitrate_bps, + } +} + +async fn cache_test_m3u_hls_item(app_state: &Arc, target: &ConfigTarget, item: M3uPlaylistItem) { + let mut playlist = crate::repository::BPlusTree::new(); + playlist.insert(item.virtual_id.get(), item); + app_state + .playlists + .cache_playlist(&target.name, crate::api::model::PlaylistStorage::M3uPlaylist(Box::new(playlist))) + .await; +} + +fn test_hls_input() -> ConfigInput { + ConfigInput { + id: 1, + name: Arc::from("test-input"), + input_type: InputType::Xtream, + url: "http://origin.example.com".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + } +} + +#[derive(Clone, Copy)] +enum TestLiveBitrateRepositoryState { + MissingDatabase, + MissingStreamItem, + ExistingHigher, + Update, + PermanentlyInapplicable, + RepositoryIoError, +} + +impl TestLiveBitrateRepositoryState { + const fn input_name(self) -> &'static str { + match self { + Self::MissingDatabase => "bandwidth-missing-database", + Self::MissingStreamItem => "bandwidth-missing-item", + Self::ExistingHigher => "bandwidth-existing-higher", + Self::Update => "bandwidth-update", + Self::PermanentlyInapplicable => "bandwidth-inapplicable", + Self::RepositoryIoError => "bandwidth-io-error", + } + } +} + +fn prepare_test_live_bitrate_repository( + input: &ConfigInput, + storage_root: &std::path::Path, + repository_state: TestLiveBitrateRepositoryState, +) { + let storage_path = + crate::repository::build_input_storage_path(&input.name, storage_root.to_string_lossy().as_ref()); + let database_path = crate::repository::get_input_m3u_playlist_file_path(&storage_path, &input.name); + match repository_state { + TestLiveBitrateRepositoryState::MissingDatabase | TestLiveBitrateRepositoryState::PermanentlyInapplicable => {} + TestLiveBitrateRepositoryState::RepositoryIoError => { + std::fs::create_dir_all(&storage_path).expect("input storage"); + std::fs::write(&database_path, b"invalid btree data").expect("corrupt input tree"); + } + TestLiveBitrateRepositoryState::MissingStreamItem + | TestLiveBitrateRepositoryState::ExistingHigher + | TestLiveBitrateRepositoryState::Update => { + std::fs::create_dir_all(&storage_path).expect("input storage"); + let (stream_ref, stored_bitrate) = match repository_state { + TestLiveBitrateRepositoryState::MissingStreamItem => ("different-channel", 0), + TestLiveBitrateRepositoryState::ExistingHigher => ("channel-a", 3_000_000), + TestLiveBitrateRepositoryState::Update => ("channel-a", 0), + TestLiveBitrateRepositoryState::MissingDatabase + | TestLiveBitrateRepositoryState::PermanentlyInapplicable + | TestLiveBitrateRepositoryState::RepositoryIoError => unreachable!(), + }; + let mut item = test_m3u_hls_item(input, 12345, stream_ref, "http://origin.test/live.m3u8"); + item.additional_properties = Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + bitrate: stored_bitrate, + ..Default::default() + }))); + let mut tree = crate::repository::BPlusTree::new(); + tree.insert(Arc::clone(&item.provider_id), item); + tree.store(&database_path).expect("input tree"); + } + } +} + +async fn prepare_runtime_bandwidth_session( + app_state: &Arc, + input: &ConfigInput, + manifest_rendered_at_ms: u64, +) -> HlsSessionHandle { + let origin_source = super::build_hls_origin_source(input, "channel-a"); + let (session, _) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + origin_source.session_key(), + origin_source, + &app_state.get_encrypt_secret(), + manifest_rendered_at_ms, + ) + .await; + let manifest = normal_manifest( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ + #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", + ); + { + let mut session_guard = session.write().await; + session_guard.apply_origin_manifest(&manifest).expect("runtime learning timeline"); + for entry in session_guard.segments.values_mut() { + entry.status = + SegmentCacheStatus::Ready { content_length: 1_000_000, ready_at_ms: manifest_rendered_at_ms }; + } + session_guard.advance_media_readiness_generation(); + session_guard.render_and_store_manifest(manifest_rendered_at_ms).expect("runtime learning manifest"); + session_guard.mark_authorized_media_access(manifest_rendered_at_ms); + } + session +} + +async fn hls_runtime_bandwidth_manifest_case( + repository_state: TestLiveBitrateRepositoryState, +) -> (HlsBandwidthPersistenceState, Option) { + let temp = tempfile::tempdir().expect("temp dir"); + let app_state = test_app_state(); + let current_config = app_state.app_config.config.load(); + app_state.app_config.config.store(Arc::new(Config { + storage_dir: temp.path().to_string_lossy().into_owned(), + ..current_config.as_ref().clone() + })); + let input = ConfigInput { + id: 7, + name: Arc::from(repository_state.input_name()), + input_type: if matches!(repository_state, TestLiveBitrateRepositoryState::PermanentlyInapplicable) { + InputType::Library + } else { + InputType::M3u + }, + ..ConfigInput::default() + }; + prepare_test_live_bitrate_repository(&input, temp.path(), repository_state); + let manifest_rendered_at_ms = super::current_time_millis(); + let session = prepare_runtime_bandwidth_session(&app_state, &input, manifest_rendered_at_ms).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let access_lease_id = HlsAccessLeaseId(format!("{}-lease", repository_state.input_name())); + let now_ms = super::current_time_millis(); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + access_lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id, + "hls-user".to_string(), + "hls-session-token".to_string(), + input.id, + "channel-a".to_string(), + 12345, + now_ms, + 60_000, + )) + .await; + + let response = super::try_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 0 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + super::HlsRuntimeBandwidthLearningContext::Eligible(&input), + ) + .await + .expect("cached media manifest response"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response_body(response).await.is_empty()); + + let bandwidth_persistence = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let state = session.read().await.bandwidth_persistence; + if !matches!(state, HlsBandwidthPersistenceState::Idle | HlsBandwidthPersistenceState::InFlight { .. }) { + break state; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("bandwidth persistence completion"); + let stored_bitrate = if matches!( + repository_state, + TestLiveBitrateRepositoryState::ExistingHigher | TestLiveBitrateRepositoryState::Update + ) { + crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") + .await + .expect("stored bitrate read") + } else { + None + }; + (bandwidth_persistence, stored_bitrate) +} + +#[tokio::test] +async fn hls_runtime_bandwidth_missing_database_retries_without_failing_media_manifest() { + let (state, _) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::MissingDatabase).await; + + assert!(matches!(state, HlsBandwidthPersistenceState::RetryAfter { .. })); +} + +#[tokio::test] +async fn hls_runtime_bandwidth_missing_item_retries_without_failing_media_manifest() { + let (state, _) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::MissingStreamItem).await; + + assert!(matches!(state, HlsBandwidthPersistenceState::RetryAfter { .. })); +} + +#[tokio::test] +async fn hls_runtime_bandwidth_existing_higher_completes_without_failing_media_manifest() { + let (state, stored_bitrate) = + hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::ExistingHigher).await; + + assert!(matches!(state, HlsBandwidthPersistenceState::Persisted { bitrate_bps: 2_000_000 })); + assert_eq!(stored_bitrate, Some(3_000_000)); +} + +#[tokio::test] +async fn hls_runtime_bandwidth_update_completes_without_failing_media_manifest() { + let (state, stored_bitrate) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::Update).await; + + assert!(matches!(state, HlsBandwidthPersistenceState::Persisted { bitrate_bps: 2_000_000 })); + assert_eq!(stored_bitrate, Some(2_000_000)); +} + +#[tokio::test] +async fn hls_runtime_bandwidth_inapplicable_and_io_error_do_not_fail_media_manifest() { + let (inapplicable, _) = + hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::PermanentlyInapplicable).await; + let (io_error, _) = hls_runtime_bandwidth_manifest_case(TestLiveBitrateRepositoryState::RepositoryIoError).await; + + assert!(matches!(inapplicable, HlsBandwidthPersistenceState::PermanentlyInapplicable { bitrate_bps: 2_000_000 })); + assert!(matches!(io_error, HlsBandwidthPersistenceState::RetryAfter { .. })); +} + +#[tokio::test] +async fn hls_runtime_bandwidth_persistence_is_entry_gated_and_deduplicated() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_state = test_app_state(); + let current_config = app_state.app_config.config.load(); + app_state.app_config.config.store(Arc::new(Config { + storage_dir: temp.path().to_string_lossy().into_owned(), + ..current_config.as_ref().clone() + })); + let input = ConfigInput { + id: 7, + name: Arc::from("runtime-bandwidth-input"), + input_type: InputType::M3u, + ..ConfigInput::default() + }; + let storage_path = crate::repository::build_input_storage_path(&input.name, temp.path().to_string_lossy().as_ref()); + std::fs::create_dir_all(&storage_path).expect("input storage"); + let item = test_m3u_hls_item(&input, 12345, "channel-a", "http://origin.test/live.m3u8"); + let mut tree = crate::repository::BPlusTree::new(); + tree.insert(Arc::clone(&item.provider_id), item); + tree.store(&crate::repository::get_input_m3u_playlist_file_path(&storage_path, &input.name)).expect("input tree"); + + let origin_source = super::build_hls_origin_source(&input, "channel-a"); + let (session, _) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + origin_source.session_key(), + origin_source, + &app_state.get_encrypt_secret(), + 1, + ) + .await; + { + let manifest = normal_manifest( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:1\n\ + #EXTINF:4.0,\n1.ts\n#EXTINF:4.0,\n2.ts\n#EXTINF:4.0,\n3.ts\n", + ); + let mut session_guard = session.write().await; + session_guard.apply_origin_manifest(&manifest).expect("runtime learning timeline"); + for entry in session_guard.segments.values_mut() { + entry.status = SegmentCacheStatus::Ready { content_length: 1_000_000, ready_at_ms: 2 }; + } + } + + assert!(super::spawn_hls_runtime_bandwidth_persistence( + &app_state, + &session, + super::HlsRuntimeBandwidthLearningContext::Disabled, + ) + .is_none()); + assert_eq!( + crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") + .await + .expect("unknown bitrate read"), + None + ); + + let task = super::spawn_hls_runtime_bandwidth_persistence( + &app_state, + &session, + super::HlsRuntimeBandwidthLearningContext::Eligible(&input), + ) + .expect("runtime persistence task"); + task.await.expect("runtime persistence completion"); + + assert_eq!( + crate::repository::load_input_live_bitrate_bps(&app_state.app_config, &input, "channel-a") + .await + .expect("persisted bitrate read"), + Some(2_000_000) + ); + assert!(super::spawn_hls_runtime_bandwidth_persistence( + &app_state, + &session, + super::HlsRuntimeBandwidthLearningContext::Eligible(&input), + ) + .is_none()); +} + +fn store_test_sources_with_target(app_state: &Arc, input: ConfigInput, target: ConfigTarget) { + let input = Arc::new(input); + let inputs = vec![Arc::clone(&input)]; + app_state.app_config.sources.store(Arc::new(SourcesConfig { + batch_files: vec![], + provider: vec![], + group_lookup: build_group_lookup(&inputs), + inputs, + sources: vec![ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![Arc::new(target)] }], + templates: None, + })); +} + +fn configure_default_test_server(app_state: &Arc) { + let users = + app_state.app_config.api_proxy.load_full().as_ref().map_or_else(Vec::new, |api_proxy| api_proxy.user.clone()); + app_state.app_config.api_proxy.store(Some(Arc::new(ApiProxyConfig { + server: vec![ApiProxyServerInfo { + name: "default".to_string(), + protocol: "http".to_string(), + host: "127.0.0.1".to_string(), + port: Some("8901".to_string()), + timezone: "UTC".to_string(), + message: String::new(), + path: None, + }], + user: users, + ..Default::default() + }))); +} + +#[test] +fn hls_custom_video_manifest_body_is_none_for_non_provisioning() { + let user = hls_custom_video_test_user(); + let manifest = build_hls_custom_video_manifest_body( + "https://example.test/iptv/", + &user, + CustomVideoStreamType::UserConnectionsExhausted, + ); + + assert!(manifest.is_none(), "non-provisioning custom video types have no static manifest body"); +} + +#[tokio::test] +async fn hls_initial_manifest_decision_wait_timeout_defaults_to_ninety_seconds() { + let app_state = test_app_state(); + assert_eq!(super::hls_initial_manifest_decision_wait_timeout(&app_state), Duration::from_secs(90)); +} + +#[tokio::test] +async fn hls_manifest_channel_unavailable_renders_inline_without_redirect() { + let app_state = test_app_state(); + enable_channel_unavailable_custom_response(&app_state); + + let response = super::hls_manifest_channel_unavailable_response_for_username(&app_state, "hls-user").await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + assert!(body.contains("#EXT-X-ENDLIST")); +} + +#[tokio::test] +async fn hls_manifest_channel_unavailable_falls_back_to_not_found_when_custom_response_is_disabled() { + let app_state = test_app_state(); + disable_custom_stream_response(&app_state); + + let response = super::hls_manifest_channel_unavailable_response_for_username(&app_state, "hls-user").await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +const RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES: u64 = 20 * 1024 * 1024; + +struct RecoveryBeforeCutoverFixture { + _temp_dir: tempfile::TempDir, + origin: TestSegmentOrigin, + origin_phase: Arc, + app_state: Arc, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + lease_id: HlsAccessLeaseId, + refresh: OriginRefreshRequest, +} + +async fn assert_initial_recovery_window(fixture: &RecoveryBeforeCutoverFixture) { + let response = try_test_hls_cached_manifest_response( + &fixture.app_state, + &fixture.session, + &fixture.lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::initial(Duration::from_secs(10)), + ) + .await + .expect("READY initial manifest"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains("/000000.ts")); + assert!(body.contains("/000002.ts")); + assert!(!body.contains("/000003.ts")); + let now_ms = super::current_time_millis(); + assert!(fixture + .app_state + .hls_proxy + .activate_access_lease( + &fixture.lease_id, + &fixture.proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, + ) + .await + .is_activated()); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, now_ms) + .await + .expect("active stripped lease"); + let snapshot = lease.last_manifest_snapshot.as_ref().expect("lease manifest snapshot"); + assert_eq!(snapshot.visible_segments.len(), 3); + assert_eq!(snapshot.last_proxy_seq, 2); + let evidence = + prepare_terminal_base_evidence(&fixture.session, fixture.app_state.hls_proxy.segment_cache(), snapshot, now_ms) + .await; + assert_eq!(evidence.track_signature(), Some(terminal_test_asset().track_signature().clone())); + evidence.release(); + extend_ready_segment_as_sparse_file(&fixture.app_state, &fixture.session, 2, RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES) + .await; + let uri = format!("/hls/shared/live/{}/{}/000002.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert_eq!(segment.headers()[header::CONTENT_LENGTH], RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES.to_string()); + assert_eq!( + u64::try_from(response_body(segment).await.len()).unwrap_or(u64::MAX), + RECOVERY_LOGICAL_LARGE_SEGMENT_BYTES + ); +} + +async fn recovery_before_cutover_fixture() -> RecoveryBeforeCutoverFixture { + let temp_dir = tempfile::tempdir().expect("recovery cache tempdir"); + let unchanged_manifest = Arc::<[u8]>::from(regression_origin_manifest(100, 6)); + let progressed_manifest = Arc::<[u8]>::from(regression_origin_manifest(101, 6)); + let segment = Arc::<[u8]>::from( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) + .as_slice(), + ); + let origin_phase = Arc::new(AtomicUsize::new(0)); + let phase = Arc::clone(&origin_phase); + let origin = spawn_test_binary_origin(Arc::new(move |path| { + if path_has_extension(path, "m3u8") { + return match phase.load(Ordering::SeqCst) { + 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&unchanged_manifest)), + 1 => TestBinaryOriginResponse::new( + StatusCode::PROXY_AUTHENTICATION_REQUIRED, + Arc::<[u8]>::from(&b"retry"[..]), + ), + _ => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&progressed_manifest)), + }; + } + TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&segment)) + })) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("recovery-regression-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = + test_app_state_with_hls_proxy_and_inputs(test_beast_hls_proxy(temp_dir.path()), vec![Arc::new(input)]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let manifest_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 1_000) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let lease_id = HlsAccessLeaseId("recovery-before-cutover".to_string()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; + let refresh = + regression_origin_refresh_request(&app_state, Arc::clone(&session), &manifest_url, Some(lease_id.clone())); + let fixture = RecoveryBeforeCutoverFixture { + _temp_dir: temp_dir, + origin, + origin_phase, + app_state, + session, + proxy_session_id, + lease_id, + refresh, + }; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + wait_for_ready_timeline(&fixture.session, 6).await; + assert_initial_recovery_window(&fixture).await; + fixture +} + +async fn run_recovery_outage(fixture: &mut RecoveryBeforeCutoverFixture) -> (u64, Option) { + let progress_generation = fixture.session.read().await.origin_control.progress_generation; + for _ in 0..2 { + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + } + assert_eq!(fixture.session.read().await.origin_control.progress_generation, progress_generation); + fixture.origin_phase.store(1, Ordering::SeqCst); + let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); + assert_eq!(fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(), plan); + fixture.refresh.acceptance_directive.trigger = HlsManifestAcceptanceTrigger::RecoveryRequired; + let requests_before = fixture.origin.manifest_request_count(); + let mut last_episode_generation = None; + for _ in 0..3 { + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("407 exhaustion cannot remove the lease"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + let session = fixture.session.read().await; + let episode = session.origin_control.acceptance_episode.as_ref().expect("bounded acceptance evidence"); + assert!(episode.full_burst_completed); + assert_eq!(episode.completed_burst_candidates, plan.total_candidates()); + last_episode_generation = Some(episode.generation.0); + } + assert!( + fixture.origin.manifest_request_count().saturating_sub(requests_before) + >= plan.total_candidates().saturating_mul(3) + ); + let uri = format!("/hls/shared/live/{}/{}/000003.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let cached = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(cached.status(), StatusCode::OK); + assert!(!response_body(cached).await.is_empty()); + (progress_generation, last_episode_generation) +} + +async fn assert_recovery_after_outage( + fixture: &mut RecoveryBeforeCutoverFixture, + progress_generation: u64, + last_episode_generation: Option, +) { + fixture.origin_phase.store(2, Ordering::SeqCst); + fixture.refresh.acceptance_directive.trigger = HlsManifestAcceptanceTrigger::RecoveryRequired; + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + wait_for_ready_timeline(&fixture.session, 7).await; + { + let session = fixture.session.read().await; + assert_eq!(session.origin_seq_highwater, Some(106)); + assert_eq!(session.proxy_next_seq, Some(7)); + assert!(session.origin_control.progress_generation > progress_generation); + assert!(session.origin_control.acceptance_episode.is_none()); + assert!(last_episode_generation + .is_some_and(|generation| session.origin_control.acceptance_generation.0 > generation)); + } + let response = try_test_hls_cached_manifest_response( + &fixture.app_state, + &fixture.session, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("recovered normal manifest"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("recovered manifest utf8"); + assert!(body.contains("/000006.ts")); + assert!(!body.contains("/terminal/")); + assert!(!body.contains("#EXT-X-ENDLIST")); + let uri = format!("/hls/shared/live/{}/{}/000006.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert!(!response_body(segment).await.is_empty()); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("recovered lease remains stored"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); +} + +#[tokio::test] +async fn recovers_before_lease_cutover_without_terminal_tail() { + let mut fixture = recovery_before_cutover_fixture().await; + let (progress_generation, last_episode_generation) = run_recovery_outage(&mut fixture).await; + assert_recovery_after_outage(&mut fixture, progress_generation, last_episode_generation).await; +} + +struct StaleOriginServers { + pinned: TestSegmentOrigin, + alternative: TestSegmentOrigin, + pinned_phase: Arc, + alternative_phase: Arc, + burst_candidates: Arc, + pinned_candidates: Arc, +} + +async fn spawn_stale_origin_servers() -> StaleOriginServers { + let pinned_manifest = Arc::<[u8]>::from(regression_origin_manifest(100, 6)); + let alternative_manifest = Arc::<[u8]>::from(regression_origin_manifest(200, 6)); + let continued_manifest = Arc::<[u8]>::from(regression_origin_manifest(201, 6)); + let segment = Arc::<[u8]>::from( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) + .as_slice(), + ); + let alternative_phase = Arc::new(AtomicUsize::new(0)); + let alternative_phase_for_handler = Arc::clone(&alternative_phase); + let alternative_segment = Arc::clone(&segment); + let alternative = spawn_test_binary_origin(Arc::new(move |path| { + if path_has_extension(path, "m3u8") { + let body = if alternative_phase_for_handler.load(Ordering::SeqCst) == 0 { + Arc::clone(&alternative_manifest) + } else { + Arc::clone(&continued_manifest) + }; + return TestBinaryOriginResponse::new(StatusCode::OK, body); + } + TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&alternative_segment)) + })) + .await; + let alternative_url = + format!("{}/live/user/pass/12345.m3u8", alternative.base_url.replacen("127.0.0.1", "localhost", 1)); + let pinned_phase = Arc::new(AtomicUsize::new(0)); + let burst_candidates = Arc::new(AtomicUsize::new(0)); + let pinned_candidates = Arc::new(AtomicUsize::new(0)); + let handler_phase = Arc::clone(&pinned_phase); + let handler_burst_candidates = Arc::clone(&burst_candidates); + let handler_pinned_candidates = Arc::clone(&pinned_candidates); + let handler_pinned_manifest = Arc::clone(&pinned_manifest); + let pinned_segment = Arc::clone(&segment); + let pinned = spawn_test_binary_origin(Arc::new(move |path| { + if !path_has_extension(path, "m3u8") { + return TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&pinned_segment)); + } + match handler_phase.load(Ordering::SeqCst) { + 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&handler_pinned_manifest)), + 1 => { + let index = handler_burst_candidates.fetch_add(1, Ordering::SeqCst); + if index.is_multiple_of(2) { + handler_pinned_candidates.fetch_add(1, Ordering::SeqCst); + TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&handler_pinned_manifest)) + } else { + TestBinaryOriginResponse::redirect(alternative_url.clone()) + } + } + _ => TestBinaryOriginResponse::redirect(alternative_url.clone()), + } + })) + .await; + StaleOriginServers { pinned, alternative, pinned_phase, alternative_phase, burst_candidates, pinned_candidates } +} + +struct StaleOriginFixture { + _temp_dir: tempfile::TempDir, + servers: StaleOriginServers, + app_state: Arc, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + lease_id: HlsAccessLeaseId, + refresh: OriginRefreshRequest, + initial_visible_tail: u64, + initial_progress_generation: u64, + initial_origin_epoch: u64, + initial_progress_at_ms: Option, +} + +async fn stale_origin_fixture() -> StaleOriginFixture { + let temp_dir = tempfile::tempdir().expect("stale-origin cache tempdir"); + let servers = spawn_stale_origin_servers().await; + let input = ConfigInput { + id: 1, + name: Arc::from("reachable-stale-origin-input"), + input_type: InputType::M3u, + url: servers.pinned.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = + test_app_state_with_hls_proxy_and_inputs(test_beast_hls_proxy(temp_dir.path()), vec![Arc::new(input)]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let manifest_url = format!("{}/live/user/pass/12345.m3u8", servers.pinned.base_url); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 1_000) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let lease_id = HlsAccessLeaseId("reachable-stale-origin".to_string()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; + let refresh = + regression_origin_refresh_request(&app_state, Arc::clone(&session), &manifest_url, Some(lease_id.clone())); + assert!(trigger_origin_refresh_sync(refresh.clone()).await); + wait_for_ready_timeline(&session, 6).await; + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::initial(Duration::from_secs(10)), + ) + .await + .expect("READY pinned-origin manifest"); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); + assert_eq!(media_uri_count(&body), 3); + let now_ms = super::current_time_millis(); + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, + ) + .await + .is_activated()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_session_id, now_ms) + .await + .expect("activated pinned-origin lease"); + let initial_visible_tail = + lease.last_manifest_snapshot.as_ref().expect("initial lease manifest snapshot").last_proxy_seq; + let (initial_progress_generation, initial_origin_epoch, initial_progress_at_ms) = { + let session = session.read().await; + assert_eq!(session.origin_seq_highwater, Some(105)); + assert_eq!(session.last_effective_manifest_host.as_deref(), Some("127.0.0.1")); + ( + session.origin_control.progress_generation, + session.origin_epoch, + session.origin_control.last_media_progress_at_ms, + ) + }; + StaleOriginFixture { + _temp_dir: temp_dir, + servers, + app_state, + session, + proxy_session_id, + lease_id, + refresh, + initial_visible_tail, + initial_progress_generation, + initial_origin_epoch, + initial_progress_at_ms, + } +} + +async fn observe_stale_origin(fixture: &mut StaleOriginFixture) -> HlsManifestAcceptanceDirective { + let requests_before = fixture.servers.pinned.manifest_request_count(); + for _ in 0..2 { + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + } + { + let session = fixture.session.read().await; + assert_eq!(session.origin_seq_highwater, Some(105)); + assert_eq!(session.origin_control.progress_generation, fixture.initial_progress_generation); + assert_eq!(session.origin_control.last_media_progress_at_ms, fixture.initial_progress_at_ms); + assert_eq!(session.origin_refresh.consecutive_failures, 0); + } + assert_eq!(fixture.servers.pinned.manifest_request_count().saturating_sub(requests_before), 2); + assert_eq!(fixture.servers.alternative.manifest_request_count(), 0); + let response = try_test_hls_cached_manifest_response( + &fixture.app_state, + &fixture.session, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("reachable stale origin keeps the committed live manifest"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("stale live manifest utf8"); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); + assert!(!body.contains("/terminal/")); + assert!(!body.contains("#EXT-X-ENDLIST")); + { + let mut session = fixture.session.write().await; + for segment in session.segments.values_mut().filter(|segment| segment.proxy_seq > fixture.initial_visible_tail) + { + segment.duration_ms = 10_000; + } + session.origin_control.last_media_progress_at_ms = Some(0); + session.advance_media_readiness_generation(); + } + let directive = match crate::api::model::hls_manifest_acceptance_directive_for_session( + &fixture.app_state.hls_ctx(), + &fixture.session, + &fixture.proxy_session_id, + ) + .await + { + HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => directive, + other => panic!("stale progress evidence must evaluate: {other:?}"), + }; + assert!(directive.trigger.recovery_required()); + assert_eq!(fixture.session.read().await.origin_control.path_condition, HlsOriginPathCondition::PublicationLate); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("stale progress evidence keeps the lease stored"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + directive +} + +async fn assert_stale_origin_handoff( + fixture: &mut StaleOriginFixture, + directive: HlsManifestAcceptanceDirective, +) -> u64 { + let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); + assert_eq!(fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(), plan); + fixture.servers.pinned_phase.store(1, Ordering::SeqCst); + let requests_before = fixture.servers.pinned.manifest_request_count(); + fixture.refresh.acceptance_directive = directive; + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + wait_for_ready_timeline(&fixture.session, 12).await; + assert_eq!( + fixture.servers.pinned.manifest_request_count().saturating_sub(requests_before), + plan.total_candidates() + ); + assert_eq!(fixture.servers.burst_candidates.load(Ordering::SeqCst), plan.total_candidates()); + assert!(fixture.servers.pinned_candidates.load(Ordering::SeqCst) > 0); + assert!(fixture.servers.alternative.manifest_request_count() >= 2); + let progress_generation = { + let session = fixture.session.read().await; + assert_eq!(session.origin_seq_highwater, Some(205)); + assert_eq!(session.proxy_next_seq, Some(12)); + assert_eq!(session.last_effective_manifest_host.as_deref(), Some("localhost")); + assert_eq!(session.origin_epoch, fixture.initial_origin_epoch.saturating_add(1)); + assert!(session.origin_control.progress_generation > fixture.initial_progress_generation); + session.origin_control.progress_generation + }; + let response = try_test_hls_cached_manifest_response( + &fixture.app_state, + &fixture.session, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("cross-host recovery manifest"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("recovered manifest utf8"); + assert!(body.contains("/000006.ts")); + assert!(body.contains("#EXT-X-DISCONTINUITY")); + assert!(!body.contains("/terminal/")); + assert!(!body.contains("#EXT-X-ENDLIST")); + let uri = format!("/hls/shared/live/{}/{}/000006.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert!(!response_body(segment).await.is_empty()); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("recovered lease remains stored"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + progress_generation +} + +async fn assert_stale_origin_continuation(fixture: &mut StaleOriginFixture, progress_generation: u64) { + fixture.servers.alternative_phase.store(1, Ordering::SeqCst); + fixture.servers.pinned_phase.store(2, Ordering::SeqCst); + fixture.refresh.acceptance_directive = HlsManifestAcceptanceDirective::none(); + fixture.refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(fixture.refresh.clone()).await); + wait_for_ready_timeline(&fixture.session, 13).await; + { + let session = fixture.session.read().await; + assert_eq!(session.origin_seq_highwater, Some(206)); + assert_eq!(session.proxy_next_seq, Some(13)); + assert_eq!(session.origin_epoch, fixture.initial_origin_epoch.saturating_add(1)); + assert!(session.origin_control.progress_generation > progress_generation); + } + let response = try_test_hls_cached_manifest_response( + &fixture.app_state, + &fixture.session, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("continued alternative-origin timeline"); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("continued manifest utf8"); + assert!(body.contains("/000012.ts")); + assert!(!body.contains("/terminal/")); + assert!(!body.contains("#EXT-X-ENDLIST")); + let uri = format!("/hls/shared/live/{}/{}/000012.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let segment = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(segment.status(), StatusCode::OK); + assert!(!response_body(segment).await.is_empty()); +} + +#[tokio::test] +async fn reachable_stale_origin_hands_off_to_progressed_origin_without_terminal_tail() { + let mut fixture = stale_origin_fixture().await; + let directive = observe_stale_origin(&mut fixture).await; + let progress_generation = assert_stale_origin_handoff(&mut fixture, directive).await; + assert_stale_origin_continuation(&mut fixture, progress_generation).await; +} + +#[tokio::test] +async fn terminal_lease_manifest_is_inline_immutable_endlist_on_canonical_path() { + const LIVE_TAIL_BYTES: &[u8] = b"original-live-tail"; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", LIVE_TAIL_BYTES).await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let cursor_before = app_state + .hls_proxy + .access_lease_response_snapshot( + &HlsAccessLeaseId(lease_id.clone()), + &ProxySessionId(proxy_session_id.clone()), + super::current_time_millis(), + ) + .await + .expect("terminal lease before media read") + .playback_cursor; + let (generation, segment_count) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; + let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/manifest.m3u8"); + let mut reloaded_api_proxy = + app_state.app_config.api_proxy.load_full().as_deref().cloned().expect("test API proxy config"); + reloaded_api_proxy.server[0].path = Some("reloaded".to_string()); + app_state.app_config.api_proxy.store(Some(Arc::new(reloaded_api_proxy))); + + let response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest is utf8"); + let repeated = get_response(Arc::clone(&app_state), &manifest_uri, None).await; + assert_eq!(repeated.status(), StatusCode::OK); + assert!(!repeated.headers().contains_key(header::LOCATION)); + assert_eq!(response_body(repeated).await, body.as_bytes()); + let live_tail = format!("/{proxy_session_id}/{lease_id}/000123.ts"); + let terminal_prefix = format!("/{proxy_session_id}/{lease_id}/terminal/{generation}/"); + assert!(body.contains(&live_tail)); + assert!(body.contains("/iptv/hls/shared/live/")); + assert!(!body.contains("/reloaded/hls/shared/live/")); + assert_eq!(body.matches(&terminal_prefix).count(), usize::from(segment_count)); + assert_eq!(body.matches("#EXT-X-DISCONTINUITY\n").count(), 1); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); + assert!(body.find(&live_tail) < body.find("#EXT-X-DISCONTINUITY\n")); + assert!(body.find("#EXT-X-DISCONTINUITY\n") < body.find(&terminal_prefix)); + + let live_tail_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/000123.ts"); + let live_tail_response = get_response(Arc::clone(&app_state), &live_tail_uri, Some("bytes=0-")).await; + assert_eq!(live_tail_response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(live_tail_response.headers()[header::CONTENT_LENGTH], LIVE_TAIL_BYTES.len().to_string()); + assert_eq!( + live_tail_response.headers()[header::CONTENT_RANGE], + format!("bytes 0-{}/{}", LIVE_TAIL_BYTES.len() - 1, LIVE_TAIL_BYTES.len()) + ); + assert_eq!(response_body(live_tail_response).await, bytes::Bytes::from_static(LIVE_TAIL_BYTES)); + let cursor_after = app_state + .hls_proxy + .access_lease_response_snapshot( + &HlsAccessLeaseId(lease_id), + &ProxySessionId(proxy_session_id), + super::current_time_millis(), + ) + .await + .expect("terminal lease after media read") + .playback_cursor; + assert_eq!(cursor_after, cursor_before); +} + +struct PreparedTerminalCutoverFixture { + _temp_dir: tempfile::TempDir, + origin: TestSegmentOrigin, + app_state: Arc, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + lease_id: HlsAccessLeaseId, + request_url: String, + base_manifest: HlsLeaseManifestSnapshot, + asset_buffer: TransportStreamBuffer, + asset: Arc, +} + +async fn prepare_terminal_cutover_bundle( + app_state: &Arc, + base_manifest: &HlsLeaseManifestSnapshot, +) -> (TransportStreamBuffer, Arc) { + let asset_buffer = app_state + .app_config + .custom_stream_response + .load_full() + .as_ref() + .and_then(|responses| responses.channel_unavailable.as_ref()) + .cloned() + .expect("configured terminal renderer"); + let asset = snapshot_terminal_media_asset(&asset_buffer).expect("terminal asset snapshot"); + let key = prepared_terminal_bundle_key(&asset, base_manifest.target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); + let state = app_state.hls_proxy.start_prepared_terminal_bundle( + Arc::clone(&asset), + base_manifest.target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + let state = match state { + HlsPreparedTerminalBundleState::Preparing { .. } => app_state + .hls_proxy + .wait_for_prepared_terminal_bundle(key) + .await + .expect("prepared terminal bundle completion"), + state => state, + }; + assert!(matches!( + state, + HlsPreparedTerminalBundleState::Ready { ref bundle } + if bundle.key == key + && bundle.segments.len() == usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT) + )); + assert_eq!(asset_buffer.finite_hls_render_count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); + (asset_buffer, asset) +} + +async fn prepared_terminal_cutover_fixture() -> PreparedTerminalCutoverFixture { + let temp_dir = tempfile::tempdir().expect("terminal cutover tempdir"); + let origin = spawn_test_encrypted_hls_origin( + AES_TEST_MANIFEST, + Arc::from(AES_TEST_KEY_BYTES), + Arc::from(AES_TEST_PLAINTEXT_SEGMENT), + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("terminal-regression-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = + test_app_state_with_hls_proxy_and_inputs(test_beast_hls_proxy(temp_dir.path()), vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + enable_channel_unavailable_custom_response(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/channel/index.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let lease_id = HlsAccessLeaseId("prepared-terminal-cutover".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &lease_id).await; + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("AES shared session cold start"); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("AES manifest utf8"); + assert!(body.contains("#EXT-X-KEY:METHOD=AES-128")); + let session = app_state.hls_proxy.sessions().get_by_key(&session_key).await.expect("AES shared session"); + wait_for_ready_timeline(&session, 6).await; + let now_ms = super::current_time_millis(); + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 120_000, valid_window_ms: 180_000 }, + ) + .await + .is_activated()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_session_id, now_ms) + .await + .expect("live AES lease"); + let base_manifest = lease.last_manifest_snapshot.as_ref().expect("frozen AES lease manifest").clone(); + assert_eq!(base_manifest.target_duration_ms, 12_000); + assert_eq!(base_manifest.visible_segments.len(), 3); + assert!(base_manifest.active_encryption.is_some()); + let (asset_buffer, asset) = prepare_terminal_cutover_bundle(&app_state, &base_manifest).await; + for segment in base_manifest.visible_segments.iter() { + let response = get_response(Arc::clone(&app_state), &segment.uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response_body(response).await.is_empty()); + } + PreparedTerminalCutoverFixture { + _temp_dir: temp_dir, + origin, + app_state, + session, + proxy_session_id, + lease_id, + request_url, + base_manifest, + asset_buffer, + asset, + } +} + +async fn apply_terminal_cutover_pressure(fixture: &PreparedTerminalCutoverFixture) { + let mut session = fixture.session.write().await; + session.origin_control.path_condition = HlsOriginPathCondition::HardFetchFailure; + let transition_buffer_seq = fixture.base_manifest.last_proxy_seq.saturating_add(1); + let commit_guard_seq = transition_buffer_seq.saturating_add(1); + for segment in + session.segments.values_mut().filter(|segment| segment.proxy_seq > fixture.base_manifest.last_proxy_seq) + { + if segment.proxy_seq == transition_buffer_seq { + segment.duration_ms = 1_000; + } else if segment.proxy_seq == commit_guard_seq { + segment.duration_ms = 2_800; + } else { + segment.status = SegmentCacheStatus::Expired; + } + } + session.advance_media_readiness_generation(); +} + +async fn terminal_cutover_acceptance_directive( + fixture: &PreparedTerminalCutoverFixture, +) -> HlsManifestAcceptanceDirective { + apply_terminal_cutover_pressure(fixture).await; + let directive = match crate::api::model::hls_manifest_acceptance_directive_for_session( + &fixture.app_state.hls_ctx(), + &fixture.session, + &fixture.proxy_session_id, + ) + .await + { + HlsManifestAcceptanceEvaluationOutcome::Evaluated(directive) => directive, + other => panic!("deterministic recovery-pressure snapshot must evaluate: {other:?}"), + }; + assert_eq!(directive.trigger, HlsManifestAcceptanceTrigger::RecoveryRequired); + let bundle_key = prepared_terminal_bundle_key( + &fixture.asset, + fixture.base_manifest.target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + let timing = directive.timing_seed.expect("prepared acceptance timing seed"); + assert_eq!(timing.required_terminal_media_key, Some(bundle_key)); + assert_eq!(timing.terminal_media_preparation, HlsTerminalMediaPreparationState::Ready { key: bundle_key }); + directive +} + +async fn exhaust_terminal_cutover_recovery( + fixture: &PreparedTerminalCutoverFixture, + directive: HlsManifestAcceptanceDirective, +) -> HlsAccessLease { + let plan = HlsManifestRecoveryBurstLevel::Beast.plan(); + let requests_before = fixture.origin.manifest_request_count(); + let mut refresh = regression_origin_refresh_request( + &fixture.app_state, + Arc::clone(&fixture.session), + &fixture.request_url, + Some(fixture.lease_id.clone()), + ); + refresh.acceptance_directive = directive; + refresh.now_ms = fixture.session.read().await.origin_refresh.next_fetch_allowed_at_ms; + assert!(trigger_origin_refresh_sync(refresh).await); + assert!(fixture.origin.manifest_request_count().saturating_sub(requests_before) >= plan.total_candidates()); + apply_terminal_cutover_pressure(fixture).await; + let bundle_key = prepared_terminal_bundle_key( + &fixture.asset, + fixture.base_manifest.target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + { + let session = fixture.session.read().await; + let episode = session.origin_control.acceptance_episode.as_ref().expect("exhausted acceptance episode"); + assert_eq!(episode.completed_burst_candidates, plan.total_candidates()); + assert!(episode.full_burst_completed); + assert_eq!(episode.timing().required_terminal_media_key, Some(bundle_key)); + assert_eq!( + episode.timing().terminal_media_preparation, + HlsTerminalMediaPreparationState::Ready { key: bundle_key } + ); + assert!(episode.exhaustion_reason().is_some()); + } + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("pressured live lease"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + assert_eq!( + lease.playback_cursor.highest_contiguous_completed_proxy_seq, + Some(fixture.base_manifest.last_proxy_seq) + ); + { + let session = fixture.session.read().await; + let ready_after_tail = session + .segments + .values() + .filter(|segment| segment.proxy_seq > fixture.base_manifest.last_proxy_seq) + .filter_map(|segment| { + matches!(segment.status, SegmentCacheStatus::Ready { .. }) + .then_some((segment.proxy_seq, segment.duration_ms)) + }) + .collect::>(); + assert_eq!( + ready_after_tail, + vec![ + (fixture.base_manifest.last_proxy_seq.saturating_add(1), 1_000), + (fixture.base_manifest.last_proxy_seq.saturating_add(2), 2_800), + ] + ); + assert!(session.origin_control.path_condition.is_degraded()); + } + lease +} + +async fn commit_prepared_terminal_cutover( + fixture: &PreparedTerminalCutoverFixture, + pressured_lease: &HlsAccessLease, +) -> (u64, u64) { + let cutover_now_ms = pressured_lease + .playback_cursor + .first_segment_completed_at_ms + .expect("measured lease playback start") + .saturating_add(fixture.base_manifest.playlist_duration_ms); + let first = commit_terminal_tail_if_lease_reserve_requires_cutover( + &fixture.app_state.hls_ctx(), + &fixture.session, + &fixture.proxy_session_id, + pressured_lease, + cutover_now_ms, + ) + .await; + assert_eq!(first, HlsTerminalResolution::Committed); + let terminal_lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("terminal lease remains stored"); + let second = commit_terminal_tail_if_lease_reserve_requires_cutover( + &fixture.app_state.hls_ctx(), + &fixture.session, + &fixture.proxy_session_id, + &terminal_lease, + cutover_now_ms.saturating_add(1), + ) + .await; + assert_eq!(second, HlsTerminalResolution::Committed); + let HlsLeasePlaybackMode::TerminalTail(plan) = terminal_lease.playback_mode else { + panic!("prepared terminal tail must commit"); + }; + assert_eq!(plan.segment_count, HLS_TERMINAL_TAIL_SEGMENT_COUNT); + (plan.generation.0, cutover_now_ms) +} + +async fn assert_prepared_terminal_cutover_manifest(fixture: &PreparedTerminalCutoverFixture, generation: u64) { + let manifest_uri = format!("/hls/shared/live/{}/{}/manifest.m3u8", fixture.proxy_session_id.0, fixture.lease_id.0); + let response = get_response(Arc::clone(&fixture.app_state), &manifest_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest utf8"); + let live_tail_path = format!( + "/{}/{}/{:06}.ts", + fixture.proxy_session_id.0, fixture.lease_id.0, fixture.base_manifest.last_proxy_seq + ); + let terminal_prefix = format!("/{}/{}/terminal/{generation}/", fixture.proxy_session_id.0, fixture.lease_id.0); + assert!(body.contains(&live_tail_path)); + assert_eq!(body.matches("#EXT-X-DISCONTINUITY\n").count(), 1); + assert!(body.find(&live_tail_path) < body.find("#EXT-X-DISCONTINUITY\n")); + let key_reset = body.find("#EXT-X-KEY:METHOD=NONE\n").expect("AES-to-clear key reset"); + let discontinuity = body.find("#EXT-X-DISCONTINUITY\n").expect("terminal discontinuity"); + assert!(key_reset < discontinuity); + assert_eq!(body.matches(&terminal_prefix).count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); + for index in 0..HLS_TERMINAL_TAIL_SEGMENT_COUNT { + assert!(body.contains(&format!("{terminal_prefix}{index}.ts"))); + } + let duration_ms = fixture.asset.duration_ms(); + let extinf = format!("#EXTINF:{}.{:03},", duration_ms / 1_000, duration_ms % 1_000); + assert_eq!(body.matches(&extinf).count(), usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); + let renders_before = fixture.asset_buffer.finite_hls_render_count(); + let zero_uri = + format!("/hls/shared/live/{}/{}/terminal/{generation}/0.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let one_uri = + format!("/hls/shared/live/{}/{}/terminal/{generation}/1.ts", fixture.proxy_session_id.0, fixture.lease_id.0); + let zero = response_body(get_response(Arc::clone(&fixture.app_state), &zero_uri, None).await).await; + let one = response_body(get_response(Arc::clone(&fixture.app_state), &one_uri, None).await).await; + assert_ne!(zero, one); + assert_eq!(fixture.asset_buffer.finite_hls_render_count(), renders_before); +} + +async fn assert_terminal_cutover_sticky_after_recovery( + fixture: &PreparedTerminalCutoverFixture, + generation: u64, + cutover_now_ms: u64, +) { + { + let mut session = fixture.session.write().await; + let recovered_manifest = + normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:83\n#EXT-X-TARGETDURATION:12\n#EXTINF:12.0,\n83.ts\n"); + session.apply_origin_manifest(&recovered_manifest).expect("later shared-session recovery"); + session.origin_control.record_media_progress(cutover_now_ms.saturating_add(1), 12_000); + } + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("sticky terminal lease"); + assert!(matches!( + lease.playback_mode, + HlsLeasePlaybackMode::TerminalTail(ref plan) if plan.generation.0 == generation + )); +} + +#[tokio::test] +async fn commits_prepared_terminal_tail_once_when_recovery_misses_deadline() { + let fixture = prepared_terminal_cutover_fixture().await; + let directive = terminal_cutover_acceptance_directive(&fixture).await; + let pressured_lease = exhaust_terminal_cutover_recovery(&fixture, directive).await; + let (generation, cutover_now_ms) = commit_prepared_terminal_cutover(&fixture, &pressured_lease).await; + assert_prepared_terminal_cutover_manifest(&fixture, generation).await; + assert_terminal_cutover_sticky_after_recovery(&fixture, generation, cutover_now_ms).await; +} + +#[tokio::test] +async fn warm_fmp4_map_cutover_without_ready_reserve_fails_closed_without_a_ts_splice() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + enable_channel_unavailable_custom_response(&app_state); + let proxy_session_id = ProxySessionId(map_hls_map(&app_state, b"fmp4-init", true).await); + let lease_id = HlsAccessLeaseId(format!("test-access-lease-{}", proxy_session_id.0)); + let now_ms = super::current_time_millis(); + let (base_proxy_seq, duration_ms) = { + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id) + .await + .expect("fMP4 shared session"); + let session = session.read().await; + let entry = session.segments.values().next().expect("fMP4 media entry"); + (entry.proxy_seq, entry.duration_ms) + }; + let snapshot = HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, + source_commit_identity: HlsManifestCommitIdentity::new(now_ms), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 0, + delivered_at_ms: now_ms, + first_proxy_seq: base_proxy_seq, + last_proxy_seq: base_proxy_seq, + visible_segments: Arc::from([HlsLeaseManifestSegment { + proxy_seq: base_proxy_seq, + duration_ms, + uri: format!("/iptv/hls/shared/live/{}/{}/{base_proxy_seq:06}.m4s", proxy_session_id.0, lease_id.0).into(), + discontinuity_before: false, + map_ref_ready: true, + encryption: None, + }]), + discontinuity_sequence: 0, + target_duration_ms: terminal_test_asset().duration_ms().saturating_add(1_000), + playlist_duration_ms: duration_ms, + last_visible_media_end_ms: duration_ms, + active_map: Some(HlsMapSignature { fingerprint: [7; 32], container: HlsMediaContainer::FragmentedMp4 }), + active_encryption: None, + container: HlsMediaContainer::FragmentedMp4, + }; + publish_test_manifest_and_exhaust_configured_acceptance(&app_state, &proxy_session_id, &lease_id, snapshot, now_ms) + .await; + let manifest_uri = format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, lease_id.0); + + let response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(!response.headers().contains_key(header::LOCATION)); + assert!(response_body(response).await.is_empty()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("failed-closed lease snapshot"); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + let terminal_uri = format!("/hls/shared/live/{}/{}/terminal/1/0.ts", proxy_session_id.0, lease_id.0); + let terminal_response = get_response(app_state, &terminal_uri, None).await; + assert_eq!(terminal_response.status(), StatusCode::NOT_FOUND); + assert!(!terminal_response.headers().contains_key(header::LOCATION)); + assert!(response_body(terminal_response).await.is_empty()); +} + +async fn terminal_head_content_length(app_state: &Arc, proxy_session_id: &str, segment_uri: &str) -> usize { + let head_response = request_response(Arc::clone(app_state), Method::HEAD, segment_uri, None).await; + assert_eq!(head_response.status(), StatusCode::OK); + assert_eq!(head_response.headers()[header::CONTENT_TYPE], "video/mp2t"); + assert_eq!(head_response.headers()[header::ACCEPT_RANGES], "bytes"); + assert!(head_response.headers()[header::CACHE_CONTROL].to_str().is_ok_and(|value| value.contains("immutable"))); + let content_length = head_response.headers()[header::CONTENT_LENGTH] + .to_str() + .expect("HEAD content length") + .parse::() + .expect("HEAD content length value"); + assert!(content_length > 0); + assert!(response_body(head_response).await.is_empty()); + assert_eq!(hls_session_last_media_at_ms(app_state, proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(app_state).await; + + let head_range = request_response(Arc::clone(app_state), Method::HEAD, segment_uri, Some("bytes=0-187")).await; + assert_eq!(head_range.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(head_range.headers()[header::CONTENT_LENGTH], "188"); + let expected_content_range = format!("bytes 0-187/{content_length}"); + assert_eq!(head_range.headers()[header::CONTENT_RANGE].to_str().ok(), Some(expected_content_range.as_str())); + assert!(response_body(head_range).await.is_empty()); + assert_eq!(hls_session_last_media_at_ms(app_state, proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(app_state).await; + content_length +} + +#[tokio::test] +async fn hls_terminal_response_serves_prepared_finite_full_and_range_bytes_per_index() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + let terminal_renderer = terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let renders_before_requests = terminal_renderer.finite_hls_render_count(); + let finalizations_before_requests = terminal_renderer.finite_hls_finalize_count(); + assert_eq!(renders_before_requests, usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); + assert_eq!(finalizations_before_requests, usize::from(HLS_TERMINAL_TAIL_SEGMENT_COUNT)); + let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; + let segment_zero_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); + let segment_one_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/1.ts"); + let repair_before = app_state.hls_proxy.segment_repair().stats().await; + let provider_connections_before = app_state.active_provider.get_provider_connections_count().await; + + let head_content_length = terminal_head_content_length(&app_state, &proxy_session_id, &segment_zero_uri).await; + + let segment_zero_response = get_response(Arc::clone(&app_state), &segment_zero_uri, None).await; + assert_eq!(segment_zero_response.status(), StatusCode::OK); + assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); + assert!(!tuliprox_core::utils::response_compression::should_compress_response(&segment_zero_response)); + assert_eq!(segment_zero_response.headers()[header::CONTENT_TYPE], "video/mp2t"); + assert_eq!(segment_zero_response.headers()[header::ACCEPT_RANGES], "bytes"); + assert!(segment_zero_response.headers()[header::CACHE_CONTROL] + .to_str() + .is_ok_and(|value| value.contains("immutable"))); + let declared_length = segment_zero_response.headers()[header::CONTENT_LENGTH] + .to_str() + .expect("finite content length header") + .parse::() + .expect("finite content length value"); + assert_eq!(declared_length, head_content_length); + let segment_zero = response_body(segment_zero_response).await; + assert_eq!(segment_zero.len(), declared_length); + assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; + assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); + assert_eq!(app_state.hls_proxy.segment_repair().stats().await, repair_before); + assert_eq!(app_state.active_provider.get_provider_connections_count().await, provider_connections_before); + + let segment_zero_again = response_body(get_response(Arc::clone(&app_state), &segment_zero_uri, None).await).await; + let segment_one = response_body(get_response(Arc::clone(&app_state), &segment_one_uri, None).await).await; + assert_eq!(segment_zero, segment_zero_again, "same terminal index is immutable"); + assert_ne!(segment_zero, segment_one, "successive terminal indices advance timestamps and continuity"); + + let range_response = get_response(Arc::clone(&app_state), &segment_zero_uri, Some("bytes=0-187")).await; + assert_eq!(range_response.status(), StatusCode::PARTIAL_CONTENT); + assert!(!tuliprox_core::utils::response_compression::should_compress_response(&range_response)); + assert_eq!(range_response.headers()[header::CONTENT_LENGTH], "188"); + let expected_content_range = format!("bytes 0-187/{declared_length}"); + assert_eq!(range_response.headers()[header::CONTENT_RANGE].to_str().ok(), Some(expected_content_range.as_str())); + assert_eq!(range_response.headers()[header::ACCEPT_RANGES], "bytes"); + assert!(range_response.headers()[header::CACHE_CONTROL].to_str().is_ok_and(|value| value.contains("immutable"))); + assert_eq!(response_body(range_response).await, segment_zero.slice(..188)); + + let unsatisfiable_range = format!("bytes={declared_length}-"); + let unsatisfiable_response = get_response(app_state, &segment_zero_uri, Some(&unsatisfiable_range)).await; + assert_eq!(unsatisfiable_response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert!(!tuliprox_core::utils::response_compression::should_compress_response(&unsatisfiable_response)); + let expected_unsatisfied_content_range = format!("bytes */{declared_length}"); + assert_eq!( + unsatisfiable_response.headers()[header::CONTENT_RANGE].to_str().ok(), + Some(expected_unsatisfied_content_range.as_str()) + ); + assert!(response_body(unsatisfiable_response).await.is_empty()); + assert_eq!( + terminal_renderer.finite_hls_render_count(), + renders_before_requests, + "terminal HTTP serving must not invoke the TS writer again" + ); + assert_eq!( + terminal_renderer.finite_hls_finalize_count(), + finalizations_before_requests, + "terminal HTTP serving must not invoke lease-specific TS finalization again" + ); +} + +async fn assert_runtime_policy_terminal_segments( + fixture: &RuntimePolicyEndpointFixture, + terminal_prefix: &str, + segment_count: u16, +) { + for index in [0, 1, segment_count.saturating_sub(1)] { + let uri = format!("{terminal_prefix}{index}.ts"); + let response = get_response(Arc::clone(&fixture.app_state), &uri, None).await; + assert_eq!(response.status(), StatusCode::OK, "terminal segment {index}"); + assert!(!response_body(response).await.is_empty()); + } +} + +#[tokio::test] +async fn resource_access_denial_commits_and_preserves_user_exhausted_tail() { + let fixture = runtime_policy_endpoint_fixture(true).await; + let session = fixture + .app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&fixture.proxy_session_id) + .await + .expect("runtime policy session"); + let origin_refresh_before = session.read().await.origin_refresh.clone(); + mark_hls_user_session_exhausted(&fixture.app_state).await; + + let denied_live = get_response(Arc::clone(&fixture.app_state), &fixture.live_segment_uri, None).await; + assert_eq!(denied_live.status(), StatusCode::FORBIDDEN); + let revoking = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("revoking lease snapshot"); + assert_eq!(revoking.state, HlsAccessLeaseState::PolicyRevoking); + assert_eq!(revoking.playback_mode, HlsLeasePlaybackMode::Live); + assert_eq!( + revoking.runtime_policy_revocation.as_ref().map(|revocation| revocation.reason), + Some(HlsRuntimeCustomTailReason::UserConnectionsExhausted) + ); + + let pending_manifest = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(pending_manifest.status(), StatusCode::SERVICE_UNAVAILABLE); + let committed_plan = wait_for_runtime_policy_terminal_plan(&fixture).await; + let manifest_response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(manifest_response.status(), StatusCode::OK); + assert!(!manifest_response.headers().contains_key(header::LOCATION)); + let manifest = String::from_utf8(response_body(manifest_response).await.to_vec()) + .expect("runtime policy terminal manifest utf8"); + assert!(manifest.ends_with("#EXT-X-ENDLIST\n")); + assert!(!manifest.contains("/cvs/hls/")); + + let committed = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("committed runtime policy lease"); + assert_eq!(committed.state, HlsAccessLeaseState::Denied); + let HlsLeasePlaybackMode::TerminalTail(plan) = committed.playback_mode else { + panic!("resource denial must commit a finite terminal plan"); + }; + assert_eq!(plan.generation, committed_plan.generation); + assert_eq!(plan.reason, HlsRuntimeCustomTailReason::UserConnectionsExhausted); + assert!(plan.segment_count >= 2); + let terminal_prefix = format!( + "/hls/shared/live/{}/{}/terminal/{}/", + fixture.proxy_session_id.0, fixture.lease_id.0, plan.generation.0 + ); + assert_eq!(manifest.matches(&terminal_prefix).count(), usize::from(plan.segment_count)); + + assert_runtime_policy_terminal_segments(&fixture, &terminal_prefix, plan.segment_count).await; + let replay = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(replay.status(), StatusCode::OK); + assert_eq!( + String::from_utf8(response_body(replay).await.to_vec()).expect("replayed runtime policy manifest utf8"), + manifest + ); + assert_eq!(get_status(Arc::clone(&fixture.app_state), &fixture.live_segment_uri).await, StatusCode::FORBIDDEN); + let retained = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("retained runtime policy plan"); + assert!(matches!( + retained.playback_mode, + HlsLeasePlaybackMode::TerminalTail(ref current) + if current.generation == plan.generation + && current.reason == HlsRuntimeCustomTailReason::UserConnectionsExhausted + )); + assert_eq!(session.read().await.origin_refresh, origin_refresh_before); +} + +#[tokio::test] +async fn manifest_touch_denied_replays_policy_tail_instead_of_standalone_clock() { + let fixture = runtime_policy_endpoint_fixture(true).await; + let _ = fixture + .app_state + .hls_proxy + .begin_runtime_policy_revocation( + &fixture.lease_id, + &fixture.proxy_session_id, + HlsRuntimeCustomTailReason::UserConnectionsExhausted, + super::current_time_millis(), + ) + .await; + assert_eq!( + fixture + .app_state + .hls_proxy + .touch_manifest_access_lease( + &fixture.lease_id, + &fixture.proxy_session_id, + super::current_time_millis(), + None, + None, + 60_000, + ) + .await, + HlsAccessLeaseTouch::Denied + ); + + let pending = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(pending.status(), StatusCode::SERVICE_UNAVAILABLE); + let committed_plan = wait_for_runtime_policy_terminal_plan(&fixture).await; + let response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("policy touch manifest utf8"); + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("touch-denied lease"); + let HlsLeasePlaybackMode::TerminalTail(plan) = lease.playback_mode else { + panic!("touch denial must retain a lease-bound terminal plan"); + }; + assert_eq!(plan.generation, committed_plan.generation); + assert!(body.contains(&format!( + "/hls/shared/live/{}/{}/terminal/{}/0.ts", + fixture.proxy_session_id.0, fixture.lease_id.0, plan.generation.0 + ))); + assert!(!body.contains("/cvs/hls/")); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); +} + +#[tokio::test] +async fn cold_user_denial_uses_standalone_finite_response() { + let fixture = runtime_policy_endpoint_fixture(false).await; + mark_hls_user_session_exhausted(&fixture.app_state).await; + + let denial = get_response(Arc::clone(&fixture.app_state), &fixture.live_segment_uri, None).await; + assert_eq!(denial.status(), StatusCode::FORBIDDEN); + let denied = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("cold denied lease"); + assert_eq!(denied.state, HlsAccessLeaseState::Denied); + assert_eq!(denied.playback_mode, HlsLeasePlaybackMode::Ended); + assert!(denied.runtime_policy_revocation.is_none()); + assert_eq!(denied.runtime_policy_denial_reason(), Some(HlsRuntimeCustomTailReason::UserConnectionsExhausted)); + + let response = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone policy manifest utf8"); + assert!(body.contains(&format!("/cvs/hls/{}/", fixture.lease_id.0))); + assert!(!body.contains("/hls-user/")); + assert!(!body.contains("/hls-pass/")); + assert!(!body.contains("/user_connections_exhausted/")); + assert!( + !body.contains(&format!("/hls/shared/live/{}/{}/terminal/", fixture.proxy_session_id.0, fixture.lease_id.0)) + ); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); +} + +#[tokio::test] +async fn hls_terminal_response_body_after_lease_denial_does_not_extend_shared_session_activity() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("terminal session"); + let segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); + let response = get_response(Arc::clone(&app_state), &segment_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let activity_after_authorized_response = + session.read().await.activity.last_authorized_media_at_ms.expect("terminal GET marks media activity"); + + let _ = app_state + .hls_proxy + .deny_access_lease( + &HlsAccessLeaseId(lease_id), + tuliprox_hls::HlsAccessLeaseDenialMode::PreserveCommittedFiniteTail, + ) + .await; + assert!(!response_body(response).await.is_empty()); + + assert_eq!( + session.read().await.activity.last_authorized_media_at_ms, + Some(activity_after_authorized_response), + "body completion after denial must not extend activity" + ); +} + +#[tokio::test] +async fn hls_terminal_response_rejects_stale_malformed_and_out_of_bounds_paths() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let (generation, segment_count) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; + let stale_generation = generation.saturating_add(1); + let stale_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{stale_generation}/0.ts"); + let out_of_bounds_uri = + format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/{segment_count}.ts"); + + let malformed_generation = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/01/0.ts"); + let non_numeric_generation = + format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/not-a-generation/0.ts"); + let overflowing_generation = + format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/18446744073709551616/0.ts"); + let malformed_file = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/00.ts"); + let wrong_extension = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.m4s"); + + for uri in [ + stale_uri, + out_of_bounds_uri, + malformed_generation, + non_numeric_generation, + overflowing_generation, + malformed_file, + wrong_extension, + ] { + let response = get_response(Arc::clone(&app_state), &uri, None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); + } +} + +#[tokio::test] +async fn expired_route_replays_already_committed_custom_tail() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let (generation, _) = terminal_test_plan_shape(&app_state, &proxy_session_id, &lease_id).await; + let proxy_session_key = ProxySessionId(proxy_session_id.clone()); + let lease_key = HlsAccessLeaseId(lease_id.clone()); + let expired_at_ms = super::current_time_millis().saturating_sub(1); + { + let mut leases = app_state.hls_proxy.access_leases().write().await; + let mut lease = leases.remove_access_lease(&lease_key).expect("terminal lease exists"); + lease.valid_until_ms = expired_at_ms; + leases.prepare_access_lease(lease); + } + let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/manifest.m3u8"); + let segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/terminal/{generation}/0.ts"); + + let manifest_response = get_response(Arc::clone(&app_state), &manifest_uri, None).await; + let segment_response = get_response(Arc::clone(&app_state), &segment_uri, None).await; + + assert_eq!(manifest_response.status(), StatusCode::OK); + assert!(!manifest_response.headers().contains_key(header::LOCATION)); + assert!(String::from_utf8(response_body(manifest_response).await.to_vec()) + .expect("expired committed manifest utf8") + .ends_with("#EXT-X-ENDLIST\n")); + assert_eq!(segment_response.status(), StatusCode::OK); + assert!(!response_body(segment_response).await.is_empty()); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_key) + .await + .expect("terminal session exists"); + assert!(session.read().await.terminal_tail_protection(&lease_key).is_some()); + + let cleanup_at_ms = super::current_time_millis(); + app_state + .hls_proxy + .handle_lifecycle_event( + &app_state.active_users, + &app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::AccessLeaseValidity { + lease_id: lease_key.clone(), + proxy_session_id: proxy_session_key, + }, + due_at_ms: cleanup_at_ms, + }, + cleanup_at_ms, + ) + .await; + + assert!(!session.read().await.has_terminal_tail_protections()); + assert!(app_state + .hls_proxy + .access_lease_response_snapshot(&lease_key, &ProxySessionId(proxy_session_id), cleanup_at_ms) + .await + .is_none()); + assert_eq!(get_response(Arc::clone(&app_state), &manifest_uri, None).await.status(), StatusCode::NOT_FOUND); + assert_eq!(get_response(app_state, &segment_uri, None).await.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn expired_route_without_session_evidence_returns_not_found_instead_of_unanchored_manifest() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"live-without-lease").await; + enable_channel_unavailable_custom_response(&app_state); + let missing_lease = "expired-without-base-evidence"; + let manifest_uri = format!("/hls/shared/live/{proxy_session_id}/{missing_lease}/manifest.m3u8"); + + let response = get_response(app_state, &manifest_uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); + assert!(response_body(response).await.is_empty()); +} + +#[tokio::test] +async fn hls_terminal_response_normal_segment_map_and_resource_routes_never_serve_terminal_fallbacks() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id, 123).await; + let normal_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{lease_id}/000123.ts"); + + let normal_segment_response = get_response(Arc::clone(&app_state), &normal_segment_uri, None).await; + + assert_eq!(normal_segment_response.status(), StatusCode::OK); + assert!(!normal_segment_response.headers().contains_key(header::LOCATION)); + assert_eq!(response_body(normal_segment_response).await, bytes::Bytes::from_static(b"original-live-tail")); + + let map_temp_dir = tempfile::tempdir().expect("map tempdir"); + let map_app_state = + test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(map_temp_dir.path(), 300))); + let map_proxy_session_id = map_hls_map(&map_app_state, b"original-map", true).await; + let map_lease_id = format!("test-access-lease-{map_proxy_session_id}"); + let map_session = map_app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(map_proxy_session_id.clone())) + .await + .expect("map session exists"); + let map_base_proxy_seq = *map_session.read().await.segments.keys().next().expect("map manifest has media"); + terminalize_existing_test_lease(&map_app_state, &map_proxy_session_id, &map_lease_id, map_base_proxy_seq).await; + let map_uri = format!("/hls/shared/live/{map_proxy_session_id}/{map_lease_id}/map/000000.mp4"); + let map_response = get_response(map_app_state, &map_uri, None).await; + assert_eq!(map_response.status(), StatusCode::NOT_FOUND); + assert!(!map_response.headers().contains_key(header::LOCATION)); + assert!(response_body(map_response).await.is_empty()); + + let resource_temp_dir = tempfile::tempdir().expect("resource tempdir"); + let resource_app_state = + test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(resource_temp_dir.path(), 300))); + let (resource_proxy_session_id, resource_id) = + map_transient_resource(&resource_app_state, "http://origin.example.com/old.ts", "ts", true).await; + let resource_lease_id = format!("test-access-lease-{resource_proxy_session_id}"); + terminalize_existing_test_lease(&resource_app_state, &resource_proxy_session_id, &resource_lease_id, 0).await; + let resource_uri = format!("/hls/shared/live/{resource_proxy_session_id}/{resource_lease_id}/r/{resource_id}.ts"); + let resource_response = get_response(resource_app_state, &resource_uri, None).await; + assert_eq!(resource_response.status(), StatusCode::NOT_FOUND); + assert!(!resource_response.headers().contains_key(header::LOCATION)); + assert!(response_body(resource_response).await.is_empty()); +} + +#[tokio::test] +async fn delayed_live_resource_completion_revalidates_after_terminal_cutover_without_a_sleep() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"ready-before-cutover").await; + let lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); + let proxy_session_key = ProxySessionId(proxy_session_id.clone()); + let access_context = test_hls_access_context(proxy_session_key.clone(), lease_id.clone()); + let live_identity = app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_session_key, super::current_time_millis()) + .await + .and_then(|lease| lease.media_identity()) + .expect("live lease identity"); + let (release_sender, release_receiver) = tokio::sync::oneshot::channel(); + let app_state_for_completion = Arc::clone(&app_state); + let access_context_for_completion = access_context.clone(); + let completion = tokio::spawn(async move { + let _ = release_receiver.await; + super::hls_live_lease_identity_is_current( + &app_state_for_completion, + &access_context_for_completion, + live_identity, + ) + .await + }); + + terminalize_existing_test_lease(&app_state, &proxy_session_id, &lease_id.0, 123).await; + release_sender.send(()).expect("release delayed completion after cutover"); + + assert!(!completion.await.expect("controlled completion task")); +} + +async fn prepare_other_live_lease( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + now_ms: u64, +) -> HlsAccessLeaseId { + let lease_id = HlsAccessLeaseId("other-live-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + 60_000, + )) + .await; + lease_id +} + +fn assert_terminal_plan_unchanged( + playback_mode: &HlsLeasePlaybackMode, + expected_generation: HlsTerminalTailGeneration, + terminal_path: HlsTerminalSegmentPath, + expected_bytes: &bytes::Bytes, +) { + let HlsLeasePlaybackMode::TerminalTail(plan) = playback_mode else { + panic!("shared lease operation cannot reactivate the terminal lease"); + }; + assert_eq!(plan.generation, expected_generation); + assert_eq!(plan.segment_bytes(terminal_path).as_ref(), Some(expected_bytes)); +} + +async fn assert_conflicted_standalone_fallback( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, +) { + let response = + super::hls_unpublished_lease_channel_unavailable_response(app_state, proxy_session_id, lease_id).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + assert!(!response.headers().contains_key(header::RETRY_AFTER)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("standalone manifest utf8"); + assert!(body.contains("#EXT-X-ENDLIST")); + assert!( + !body.contains("/hls/shared/live/"), + "standalone response must not expose a normal segment without a readiness path" + ); + assert_eq!( + session.read().await.origin_control.path_condition, + HlsOriginPathCondition::AcceptanceConflict, + "lease-local fallback must not relax deterministic conflict evidence" + ); +} + +#[tokio::test] +async fn reused_conflicted_session_standalone_fallback_preserves_terminal_lease_during_recovery() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + enable_channel_unavailable_custom_response(&app_state); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"original-live-tail").await; + let terminal_lease_id = format!("test-access-lease-{proxy_session_id}"); + terminalize_existing_test_lease(&app_state, &proxy_session_id, &terminal_lease_id, 123).await; + let proxy_session_key = ProxySessionId(proxy_session_id.clone()); + let now_ms = super::current_time_millis(); + let terminal_before = app_state + .hls_proxy + .access_lease_response_snapshot(&HlsAccessLeaseId(terminal_lease_id.clone()), &proxy_session_key, now_ms) + .await + .expect("terminal lease exists before the other lease"); + let HlsLeasePlaybackMode::TerminalTail(terminal_plan_before) = terminal_before.playback_mode else { + panic!("original lease is terminal"); + }; + let terminal_path = HlsTerminalSegmentPath { generation: terminal_plan_before.generation, index: 0 }; + let terminal_bytes_before = + terminal_plan_before.segment_bytes(terminal_path).expect("terminal segment zero is immutable"); + let other_lease_id = prepare_other_live_lease(&app_state, &proxy_session_key, now_ms).await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_key) + .await + .expect("shared session exists"); + session.write().await.origin_control.path_condition = HlsOriginPathCondition::AcceptanceConflict; + + assert_conflicted_standalone_fallback(&app_state, &session, &proxy_session_key, &other_lease_id).await; + + let terminal_after_fallback = app_state + .hls_proxy + .access_lease_response_snapshot( + &HlsAccessLeaseId(terminal_lease_id.clone()), + &proxy_session_key, + now_ms.saturating_add(1), + ) + .await + .expect("terminal lease remains stored after standalone fallback"); + assert_terminal_plan_unchanged( + &terminal_after_fallback.playback_mode, + terminal_plan_before.generation, + terminal_path, + &terminal_bytes_before, + ); + + assert!(app_state + .hls_proxy + .activate_access_lease( + &other_lease_id, + &proxy_session_key, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 5_000, valid_window_ms: 60_000 }, + ) + .await + .is_activated()); + + let recovered_manifest = + normal_manifest("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:124\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\n124.ts\n"); + { + let mut session = session.write().await; + session.apply_origin_manifest(&recovered_manifest).expect("recovery manifest commits"); + session.origin_control.record_media_progress(now_ms.saturating_add(1), 4_000); + } + + let terminal = app_state + .hls_proxy + .access_lease_response_snapshot( + &HlsAccessLeaseId(terminal_lease_id), + &proxy_session_key, + now_ms.saturating_add(2), + ) + .await + .expect("terminal lease remains stored"); + let other = app_state + .hls_proxy + .access_lease_response_snapshot(&other_lease_id, &proxy_session_key, now_ms.saturating_add(2)) + .await + .expect("other live lease remains stored"); + assert_terminal_plan_unchanged( + &terminal.playback_mode, + terminal_plan_before.generation, + terminal_path, + &terminal_bytes_before, + ); + assert_eq!(other.playback_mode, HlsLeasePlaybackMode::Live); +} + +#[test] +fn hls_custom_video_manifest_uses_live_six_segment_window_for_provisioning() { + let user = hls_custom_video_test_user(); + let manifest = + build_hls_custom_video_manifest_body("https://example.test/iptv", &user, CustomVideoStreamType::Provisioning) + .expect("provisioning manifest does not depend on one looping asset duration"); + + assert!(manifest.contains("#EXT-X-TARGETDURATION:2")); + assert!(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")); + assert!(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-INDEPENDENT-SEGMENTS\n")); + assert!(!manifest.contains("#EXT-X-DISCONTINUITY-SEQUENCE")); + assert!(!manifest.contains("#EXT-X-SESSION-DATA")); + assert!(!manifest.contains("#EXT-X-ENDLIST")); + assert!(!manifest.contains("#EXT-X-DISCONTINUITY\n")); + assert_eq!(manifest.matches("#EXTINF:2.000000,").count(), 6); + for index in 0..6 { + assert!( + manifest.contains(&format!("https://example.test/iptv/cvs/hls/viewer/secret/provisioning_{index:03}.ts")) + ); + } + assert!(!manifest.contains("https://example.test/iptv/cvs/hls/viewer/secret/provisioning.ts")); + assert_eq!( + crate::api::model::hls_panel_provisioning_manifest_path(&user, 80510), + "/cvs/hls/viewer/secret/provisioning.m3u8?id=80510" + ); + assert!(!manifest.contains("provisioning.ts?")); + assert!(!manifest.contains("virtual_id")); + assert!(!manifest.contains("&seq=")); +} + +#[test] +fn hls_response_uses_rfc8216_content_type_and_remains_tower_compressible() { + let response = super::hls_response("#EXTM3U\n".to_string()).into_response(); + + assert_eq!(response.headers().get(header::CONTENT_TYPE).unwrap(), "application/vnd.apple.mpegurl"); + assert!(tuliprox_core::utils::response_compression::should_compress_response(&response)); +} + +#[test] +fn virtual_hls_entry_path_uses_single_manifest_extension() { + let user = hls_custom_video_test_user(); + let xtream_target = ConfigTarget::from(&shared::model::ConfigTargetDto { + output: vec![shared::model::TargetOutputDto::Xtream(shared::model::XtreamTargetOutputDto::default())], + ..Default::default() + }); + let xtream_input = ConfigInput { input_type: InputType::Xtream, ..ConfigInput::default() }; + let m3u_target = ConfigTarget::from(&shared::model::ConfigTargetDto::default()); + let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() }; + + let xtream_path = super::build_virtual_hls_entry_path(&xtream_target, &xtream_input, &user, 59); + let m3u_path = super::build_virtual_hls_entry_path(&m3u_target, &m3u_input, &user, 59); + + assert_eq!(xtream_path, "/live/viewer/secret/59.m3u8"); + assert_eq!(m3u_path, "/m3u-stream/live/viewer/secret/59.m3u8"); + assert!(!xtream_path.contains("..m3u8")); + assert!(!m3u_path.contains("..m3u8")); +} + +#[test] +fn hls_manifest_headers_apply_disabled_headers_and_default_user_agent_policy() { + let mut input_headers = HashMap::new(); + input_headers.insert("User-Agent".to_string(), "Input-UA".to_string()); + input_headers.insert("Accept-Language".to_string(), "de".to_string()); + input_headers.insert("Accept-Encoding".to_string(), "gzip".to_string()); + input_headers.insert("Authorization".to_string(), "Bearer input-secret".to_string()); + input_headers.insert("X-Origin-Secret".to_string(), "input-secret".to_string()); + + let mut request_headers = HeaderMap::new(); + request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-")); + request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + request_headers.insert(header::USER_AGENT, HeaderValue::from_static("Client-UA")); + request_headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer client-secret")); + request_headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret")); + request_headers.insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); + request_headers.insert(header::HOST, HeaderValue::from_static("proxy.example.com")); + request_headers.insert(HeaderName::from_static("x-blocked"), HeaderValue::from_static("client")); + request_headers.insert(HeaderName::from_static("cf-ray"), HeaderValue::from_static("cf")); + + let disabled = ReverseProxyDisabledHeaderConfig { + referer_header: false, + x_header: true, + cloudflare_header: true, + custom_header: vec!["X-Origin-Secret".to_string()], + }; + let headers = build_hls_manifest_request_headers( + &input_headers, + &request_headers, + Some(&disabled), + Some("Default-UA"), + Some("Channel-UA"), + ); + + assert_eq!(headers.get(header::USER_AGENT).expect("user agent"), "Channel-UA"); + assert_eq!(headers.get("accept-language").expect("accept language"), "de"); + assert_eq!(headers.get(header::ACCEPT_ENCODING).expect("accept encoding"), "identity"); + assert!(!headers.contains_key(header::RANGE)); + assert!(!headers.contains_key(header::AUTHORIZATION)); + assert!(!headers.contains_key(header::COOKIE)); + assert!(!headers.contains_key("proxy-authorization")); + assert!(!headers.contains_key(header::HOST)); + assert!(!headers.contains_key("x-origin-secret")); + assert!(!headers.contains_key("x-blocked")); + assert!(!headers.contains_key("cf-ray")); +} + +#[tokio::test] +async fn legacy_hls_manifest_decodes_supported_origin_codings_and_enforces_identity() { + const MANIFEST: &str = "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:4.0,\nsegment.ts\n"; + + for coding in ["gzip", "deflate", "br", "zstd"] { + let encoded = encode_test_manifest(coding, MANIFEST.as_bytes()).await; + let origin = spawn_test_encoded_manifest_origin(Some(coding), encoded, Duration::ZERO).await; + let input = legacy_manifest_test_input(&origin); + let client_headers = legacy_manifest_test_client_headers(); + + let (manifest, final_url, _) = super::download_legacy_hls_manifest(&test_app_state(), &input, &client_headers) + .await + .unwrap_or_else(|error| panic!("{coding} manifest should decode: {error}")); + + assert_eq!(manifest, MANIFEST, "coding={coding}"); + assert_eq!(final_url, input.url, "coding={coding}"); + let requests = origin.requests.lock().await; + assert_eq!(requests.len(), 1, "coding={coding}"); + assert!( + requests[0].to_ascii_lowercase().contains("\r\naccept-encoding: identity\r\n"), + "coding={coding}, request={}", + requests[0] + ); + } +} + +#[tokio::test] +async fn legacy_hls_manifest_handles_identity_and_headerless_gzip_magic() { + const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-TARGETDURATION:4\n"; + let cases = [("identity", MANIFEST.to_vec()), ("gzip-magic", encode_test_manifest("gzip", MANIFEST).await)]; + + for (case, body) in cases { + let origin = spawn_test_encoded_manifest_origin(None, body, Duration::ZERO).await; + let input = legacy_manifest_test_input(&origin); + + let (manifest, _, _) = + super::download_legacy_hls_manifest(&test_app_state(), &input, &legacy_manifest_test_client_headers()) + .await + .unwrap_or_else(|error| panic!("{case} manifest should decode: {error}")); + + assert_eq!(manifest.as_bytes(), MANIFEST, "case={case}"); + } +} + +#[tokio::test] +async fn legacy_hls_manifest_limit_applies_after_decompression() { + let decoded = vec![b'x'; MAX_HLS_MANIFEST_BYTES + 1]; + let origin = + spawn_test_encoded_manifest_origin(Some("gzip"), encode_test_manifest("gzip", &decoded).await, Duration::ZERO) + .await; + let input = legacy_manifest_test_input(&origin); + + let error = super::download_legacy_hls_manifest(&test_app_state(), &input, &legacy_manifest_test_client_headers()) + .await + .expect_err("decoded manifest above limit must fail"); + + assert!(matches!( + error.get_ref().and_then(|source| source.downcast_ref()), + Some(crate::utils::content_coding::ContentBodyReadError::LimitExceeded { limit }) + if *limit == MAX_HLS_MANIFEST_BYTES + )); +} + +#[tokio::test] +async fn legacy_hls_manifest_deadline_includes_full_body_read() { + let origin = spawn_test_encoded_manifest_origin(None, b"#EXTM3U\n".to_vec(), Duration::from_millis(100)).await; + let input = legacy_manifest_test_input(&origin); + let hls_config = HlsCacheConfig::from(&HlsCacheConfigDto { + origin_manifest_timeout_ms: shared::model::Millis::new(10), + ..Default::default() + }); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); + + let error = super::download_legacy_hls_manifest(&app_state, &input, &legacy_manifest_test_client_headers()) + .await + .expect_err("complete manifest body read must honor the deadline"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); +} + +#[tokio::test] +async fn legacy_hls_manifest_distinguishes_invalid_utf8_from_decoder_failure() { + let invalid_utf8_origin = spawn_test_encoded_manifest_origin(None, vec![0xff], Duration::ZERO).await; + let invalid_utf8_input = legacy_manifest_test_input(&invalid_utf8_origin); + let invalid_utf8 = super::download_legacy_hls_manifest( + &test_app_state(), + &invalid_utf8_input, + &legacy_manifest_test_client_headers(), + ) + .await + .expect_err("invalid UTF-8 must fail"); + + let corrupt_origin = + spawn_test_encoded_manifest_origin(Some("gzip"), vec![0x1f, 0x8b, 0x08, 0x00], Duration::ZERO).await; + let corrupt_input = legacy_manifest_test_input(&corrupt_origin); + let decoder_failure = + super::download_legacy_hls_manifest(&test_app_state(), &corrupt_input, &legacy_manifest_test_client_headers()) + .await + .expect_err("corrupt gzip must fail"); + + assert_eq!(invalid_utf8.kind(), std::io::ErrorKind::InvalidData); + assert!(crate::utils::content_coding::content_decoding_error_from_io(&decoder_failure).is_some()); +} + +#[test] +fn hls_proxy_public_path_prefix_rewrites_only_proxy_hls_uri_surfaces() { + let body = concat!( + "#EXTM3U\n", + "#EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/proxy-id/r/key.key\",IV=0x1\n", + "#EXT-X-MAP:URI=\"/hls/shared/live/proxy-id/map/000000.mp4\",BYTERANGE=\"10@0\"\n", + "#EXT-X-PART:DURATION=1.0,URI=\"/hls/shared/live/proxy-id/r/part.m4s\"\n", + "#EXT-X-MEDIA-SEQUENCE:7\n", + "#EXTINF:4.0,\n", + "/hls/shared/live/proxy-id/000007.ts\n", + "https://origin.example.com/not-proxy.ts\n", + ); + + let prefixed = super::apply_hls_proxy_public_path_prefix(body.to_string(), Some("/iptv/")); + + assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/r/key.key\"")); + assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/map/000000.mp4\"")); + assert!(prefixed.contains("URI=\"/iptv/hls/shared/live/proxy-id/r/part.m4s\"")); + assert!(prefixed.contains("\n/iptv/hls/shared/live/proxy-id/000007.ts\n")); + assert!(prefixed.contains("#EXT-X-MEDIA-SEQUENCE:7")); + assert!(prefixed.contains("https://origin.example.com/not-proxy.ts")); +} + +#[test] +fn hls_proxy_public_path_prefix_keeps_body_unchanged_without_server_path() { + let body = "#EXTM3U\n#EXTINF:4.0,\n/hls/shared/live/proxy-id/000007.ts\n".to_string(); + + assert_eq!(super::apply_hls_proxy_public_path_prefix(body.clone(), None), body); + assert_eq!(super::apply_hls_proxy_public_path_prefix(body.clone(), Some("/")), body); +} + +#[test] +fn hls_manifest_materialization_uses_proxy_paths_without_provider_or_legacy_route() { + let body = format!( + "#EXTM3U\n#EXTINF:4.0,\n/hls/shared/live/proxy-id/{}/000123.ts\n", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ); + let lease_id = HlsAccessLeaseId("access-lease".to_string()); + + let materialized = super::materialize_hls_access_manifest(&body, &lease_id, Some("/iptv")); + + assert!(materialized.contains("/iptv/hls/shared/live/proxy-id/access-lease/000123.ts")); + assert!(!materialized.contains("provider://")); + assert!(!materialized.contains("/hls/hls-user/")); + assert!(!materialized.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); +} + +#[test] +fn hls_cache_origin_entry_url_preserves_provider_scheme_as_failover() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "demo".into(), + urls: vec!["http://origin.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input = ConfigInput { provider_configs: Some(vec![Arc::clone(&provider)]), ..ConfigInput::default() }; + + let origin = + super::resolve_hls_cache_origin_entry_url(&input, "provider://demo/live/account-a/token-a/1025130.m3u8") + .expect("provider entry url should resolve"); + + assert_eq!(origin.session_entry_url.as_str(), "provider://demo/live/account-a/token-a/1025130.m3u8"); + assert_eq!( + origin.session_entry_url.url_failover_provider().expect("provider failover config").name.as_ref(), + "demo" + ); + let provider_key = super::build_hls_origin_source(&input, "1025130").session_key(); + let direct_key = super::build_hls_origin_source(&input, "1025130").session_key(); + assert_eq!(provider_key, direct_key); + assert_eq!(provider_key.stable_value(), "input:0|hls|1025130"); + assert!(!provider_key.stable_value().contains("provider://")); + assert!(!provider_key.stable_value().contains("origin.example.com")); +} + +#[test] +fn hls_cache_origin_entry_url_does_not_attach_url_failover_provider_to_http_url() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "demo".into(), + urls: vec!["http://origin.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input = ConfigInput { provider_configs: Some(vec![provider]), ..ConfigInput::default() }; + + let origin = super::resolve_hls_cache_origin_entry_url( + &input, + "http://origin.example.com/live/account-a/token-a/1025130.m3u8", + ) + .expect("http entry url should resolve"); + + assert_eq!(origin.session_entry_url.as_str(), "http://origin.example.com/live/account-a/token-a/1025130.m3u8"); + assert!(origin.session_entry_url.url_failover_provider().is_none()); +} + +#[test] +fn hls_origin_source_kind_covers_xtream_m3u_and_direct_media_playlist() { + assert_eq!(super::hls_origin_source_kind(InputType::Xtream), HlsOriginSourceKind::XtreamLive); + assert_eq!(super::hls_origin_source_kind(InputType::M3u), HlsOriginSourceKind::M3uMediaPlaylist); + assert_eq!(super::hls_origin_source_kind(InputType::Library), HlsOriginSourceKind::DirectMediaPlaylist); +} + +#[test] +fn hls_manifest_extension_helper_does_not_create_double_dot_urls() { + assert_eq!( + super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123.m3u8"), + "http://origin.example.com/live/user/pass/1025123.m3u8" + ); + assert_eq!( + super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123..m3u8"), + "http://origin.example.com/live/user/pass/1025123.m3u8" + ); + assert_eq!( + super::ensure_hls_manifest_extension("http://origin.example.com/live/user/pass/1025123..?token=1"), + "http://origin.example.com/live/user/pass/1025123.m3u8?token=1" + ); + assert_eq!( + super::ensure_hls_manifest_extension("provider://mirror/live/user/pass/1025123..m3u8"), + "provider://mirror/live/user/pass/1025123.m3u8" + ); +} + +#[test] +fn hls_origin_resolution_preserves_legacy_built_xtream_origin_url() { + let input = ConfigInput { + id: 7, + name: Arc::from("xtream"), + input_type: InputType::Xtream, + url: "http://origin.example.com/base".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + ..ConfigInput::default() + }; + + let origin = super::build_hls_origin_resolution(&input, "http://other.example.com/live/other/creds/1025126.m3u8") + .expect("xtream origin should resolve"); + + assert_eq!(origin.session_entry_url.as_str(), "http://other.example.com/live/other/creds/1025126.m3u8"); + assert_eq!(origin.hls_url, origin.session_entry_url.as_str()); + assert!(origin.session_entry_url.url_failover_provider().is_none()); +} + +#[test] +fn hls_origin_resolution_keeps_provider_failover_out_of_identity() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "mirror-group".into(), + urls: vec!["http://mirror-a.example.com".into(), "http://mirror-b.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input = ConfigInput { + id: 7, + name: Arc::from("xtream"), + input_type: InputType::Xtream, + url: "provider://mirror-group".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + provider_configs: Some(vec![Arc::clone(&provider)]), + ..ConfigInput::default() + }; + + let origin = + super::build_hls_origin_resolution(&input, "provider://mirror-group/live/source-user/source-pass/1025126.m3u8") + .expect("provider failover origin should resolve"); + let failover_key = super::build_hls_origin_source(&input, "80510").session_key(); + let direct_key = super::build_hls_origin_source(&input, "80510").session_key(); + + assert_eq!(origin.session_entry_url.as_str(), "provider://mirror-group/live/source-user/source-pass/1025126.m3u8"); + assert!(origin.session_entry_url.url_failover_provider().is_some()); + assert_eq!(failover_key, direct_key); + assert!(!failover_key.stable_value().contains("provider://")); + assert!(!failover_key.stable_value().contains("mirror-a.example.com")); + assert!(!failover_key.stable_value().contains("mirror-b.example.com")); +} + +#[test] +fn hls_origin_resolution_uses_m3u_playlist_item_url() { + let input = ConfigInput { + id: 9, + name: Arc::from("m3u"), + input_type: InputType::M3u, + url: "http://playlist.example.com/list.m3u".to_string(), + ..ConfigInput::default() + }; + + let origin = super::build_hls_origin_resolution(&input, "http://media.example.com/live/channel/index.m3u8") + .expect("m3u hls origin should resolve"); + let source = super::build_hls_origin_source(&input, "stable-item"); + + assert_eq!(origin.session_entry_url.as_str(), "http://media.example.com/live/channel/index.m3u8"); + assert_eq!(source.source_kind, HlsOriginSourceKind::M3uMediaPlaylist); + assert_eq!(source.session_key().stable_value(), "input:9|hls|stable-item"); +} + +#[test] +fn provider_failover_mirror_change_keeps_same_hls_session_identity() { + let provider_a = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "mirror-group".into(), + urls: vec!["http://mirror-a.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let provider_b = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "mirror-group".into(), + urls: vec!["http://mirror-b.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input_a = ConfigInput { + id: 7, + name: Arc::from("xtream"), + input_type: InputType::Xtream, + url: "provider://mirror-group".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + provider_configs: Some(vec![provider_a]), + ..ConfigInput::default() + }; + let input_b = ConfigInput { provider_configs: Some(vec![provider_b]), ..input_a.clone() }; + + let origin_a = super::build_hls_origin_resolution(&input_a, "provider://mirror-group/a.m3u8") + .expect("provider failover origin a should resolve"); + let origin_b = super::build_hls_origin_resolution(&input_b, "provider://mirror-group/b.m3u8") + .expect("provider failover origin b should resolve"); + let key_a = super::build_hls_origin_source(&input_a, "80510").session_key(); + let key_b = super::build_hls_origin_source(&input_b, "80510").session_key(); + let secret = b"rewrite-secret"; + + assert!(origin_a.session_entry_url.url_failover_provider().is_some()); + assert!(origin_b.session_entry_url.url_failover_provider().is_some()); + assert_eq!(key_a, key_b); + assert_eq!(key_a.stable_value(), "input:7|hls|80510"); + assert_eq!(build_proxy_session_id(&key_a, secret), build_proxy_session_id(&key_b, secret)); + assert!(!key_a.stable_value().contains("provider://")); + assert!(!key_a.stable_value().contains("mirror-a.example.com")); + assert!(!key_a.stable_value().contains("mirror-b.example.com")); +} + +#[test] +fn hls_runtime_origin_fetch_url_uses_selected_provider_account() { + let input = ConfigInput { + name: Arc::from("source"), + url: "http://source.example.com".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::Xtream, + ..ConfigInput::default() + }; + let provider_input = ConfigInput { + id: 7, + name: Arc::from("selected-provider"), + url: "http://provider.example.com".to_string(), + username: Some("provider-user".to_string()), + password: Some("provider-pass".to_string()), + input_type: InputType::Xtream, + max_connections: 1, + ..ConfigInput::default() + }; + let provider = Arc::new(RuntimeProviderConfig::new( + &provider_input, + Arc::new(tokio::sync::RwLock::new(ProviderConfigConnection::default())), + Arc::new(|_, _| {}), + )); + + let fetch_url = super::build_hls_origin_fetch_url( + &input, + "http://source.example.com/live/source-user/source-pass/12345.m3u8", + "http://source.example.com/live/source-user/source-pass/12345.m3u8", + Some(&provider), + ) + .expect("fetch url should be rewritten"); + + assert_eq!(fetch_url, "http://provider.example.com/live/provider-user/provider-pass/12345.m3u8"); + + let failover_provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "demo".into(), + urls: vec!["http://mirror-a.example.com".into(), "http://mirror-b.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let provider_scheme_input = ConfigInput { + name: Arc::from("source"), + url: "provider://demo".to_string(), + username: Some("source-user".to_string()), + password: Some("source-pass".to_string()), + input_type: InputType::Xtream, + provider_configs: Some(vec![Arc::clone(&failover_provider)]), + ..ConfigInput::default() + }; + let provider_scheme_without_account_rewrite = super::build_hls_origin_fetch_url( + &provider_scheme_input, + "provider://demo/live/source-user/source-pass/12345.m3u8", + "provider://demo/live/source-user/source-pass/12345.m3u8", + None, + ) + .expect("provider scheme fetch url should remain failover capable"); + assert_eq!(provider_scheme_without_account_rewrite, "provider://demo/live/source-user/source-pass/12345.m3u8"); + + let provider_scheme_fetch_url = super::build_hls_origin_fetch_url( + &provider_scheme_input, + "provider://demo/live/source-user/source-pass/12345.m3u8", + "provider://demo/live/source-user/source-pass/12345.m3u8", + Some(&provider), + ) + .expect("provider scheme fetch url should use selected runtime account without losing failover"); + + assert_eq!(provider_scheme_fetch_url, "provider://demo/live/provider-user/provider-pass/12345.m3u8"); + let origin_entry = super::LiveHlsOriginEntry::parse_with_provider_configs( + &provider_scheme_without_account_rewrite, + Some(Arc::clone(&failover_provider)), + Some(Arc::clone(&provider)), + ) + .expect("provider origin entry"); + let input_source = origin_entry.to_input_source(); + assert_eq!(input_source.url, provider_scheme_without_account_rewrite); + assert_eq!(input_source.username.as_deref(), Some("provider-user")); + assert_eq!(input_source.password.as_deref(), Some("provider-pass")); + + let failover_context = super::hls_url_failover_provider_for_origin_context( + &provider_scheme_input, + "provider://demo/live/source-user/source-pass/12345.m3u8", + "provider://demo/live/source-user/source-pass/12345.m3u8", + &provider_scheme_fetch_url, + ) + .expect("provider failover context"); + assert_eq!(failover_context.name.as_ref(), "demo"); +} + +#[test] +fn hls_origin_entry_attaches_url_failover_provider_only_to_provider_scheme_fetch_url() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "demo".into(), + urls: vec!["http://mirror.example.com".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + + let http_provider = super::effective_hls_url_failover_provider_for_fetch_url( + "http://provider.example.com/live/user/pass/12345.m3u8", + None, + Some(Arc::clone(&provider)), + ); + let http_entry = super::LiveHlsOriginEntry::parse_with_url_failover_provider( + "http://provider.example.com/live/user/pass/12345.m3u8", + http_provider, + ) + .expect("http origin entry"); + assert!(http_entry.url_failover_provider().is_none()); + + let provider_scheme_provider = super::effective_hls_url_failover_provider_for_fetch_url( + "provider://demo/live/user/pass/12345.m3u8", + None, + Some(Arc::clone(&provider)), + ); + let provider_entry = super::LiveHlsOriginEntry::parse_with_url_failover_provider( + "provider://demo/live/user/pass/12345.m3u8", + provider_scheme_provider, + ) + .expect("provider origin entry"); + assert_eq!(provider_entry.url_failover_provider().expect("provider").name.as_ref(), "demo"); +} + +#[tokio::test] +async fn hls_access_lease_validity_uses_session_idle_timeout_not_cache_duration() { + let hls_dto = HlsCacheConfigDto { + cache_duration: shared::model::Secs::new(900), + session_idle_timeout: shared::model::Secs::new(42), + ..Default::default() + }; + let hls_config = HlsCacheConfig::from(&hls_dto); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); + + assert_eq!(super::hls_access_lease_ttl_ms(&app_state), 42_000); +} + +#[tokio::test] +async fn hls_access_lease_active_window_uses_two_target_durations() { + let app_state = test_app_state(); + let key = HlsSessionKey::new(1, "access-window-stream"); + let (session, _) = + app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", super::current_time_millis()).await; + session.write().await.target_duration = Some(11); + + let timing = super::hls_access_lease_timing_for_session(&app_state, &session).await; + + assert_eq!(timing.active_window_ms, 22_000); + assert_eq!(timing.valid_window_ms, super::hls_access_lease_ttl_ms(&app_state)); +} + +#[tokio::test] +async fn hls_lifecycle_active_timer_moves_access_lease_to_idle() { + let app_state = test_app_state(); + let now_ms = super::current_time_millis(); + let lease_id = HlsAccessLeaseId("lifecycle-lease".to_string()); + let key = HlsSessionKey::new(1, "lifecycle-stream"); + let (session, _) = app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "lifecycle-stream".to_string(), + 123, + now_ms, + 60_000, + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 1, valid_window_ms: 60_000 }, + ) + .await + .is_activated()); + session.write().await.activity.active_access_lease_count = 1; + + app_state + .hls_proxy + .handle_lifecycle_event( + &app_state.active_users, + &app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::AccessLeaseActive { + lease_id: lease_id.clone(), + proxy_session_id: proxy_session_id.clone(), + }, + due_at_ms: now_ms.saturating_add(1), + }, + now_ms.saturating_add(2), + ) + .await; + + assert_eq!( + app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), + Some(HlsAccessLeaseState::Idle) + ); + assert_eq!(session.read().await.activity.active_access_lease_count, 0); +} + +#[tokio::test] +async fn hls_lifecycle_validity_timer_removes_expired_access_lease() { + let mut hls_cache = HlsCacheConfigDto::default(); + hls_cache.segment_repair.max_level = HlsSegmentRepairMode::Low; + let hls_config = HlsCacheConfig::from(&hls_cache); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); + let now_ms = super::current_time_millis(); + let proxy_session_id = ProxySessionId("lifecycle-validity-proxy".to_string()); + let lease_id = HlsAccessLeaseId("lifecycle-validity-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "lifecycle-stream".to_string(), + 123, + now_ms, + 1, + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 1, valid_window_ms: 1 }, + ) + .await + .is_activated()); + let repair_before = app_state.hls_proxy.segment_repair().stats().await; + assert_eq!(repair_before.windows, 1); + assert_eq!(repair_before.generations, 1); + + app_state + .hls_proxy + .handle_lifecycle_event( + &app_state.active_users, + &app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::AccessLeaseValidity { + lease_id: lease_id.clone(), + proxy_session_id: proxy_session_id.clone(), + }, + due_at_ms: now_ms.saturating_add(1), + }, + now_ms.saturating_add(2), + ) + .await; + + assert_eq!( + app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), + None + ); + let repair_after = app_state.hls_proxy.segment_repair().stats().await; + assert_eq!(repair_after.windows, 0); + assert_eq!(repair_after.generations, 0); +} + +#[tokio::test] +async fn hls_lifecycle_validity_timer_removes_expired_pending_access_lease() { + let app_state = test_app_state(); + let now_ms = super::current_time_millis(); + let key = HlsSessionKey::new(1, "pending-expiry-stream"); + let (session, _) = app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let lease_id = HlsAccessLeaseId("pending-expiry-lease".to_string()); + let active_lease_id = HlsAccessLeaseId("active-soft-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "pending-expiry-stream".to_string(), + 123, + now_ms, + 1, + )) + .await; + app_state + .hls_proxy + .prepare_access_lease( + HlsAccessLease::pending( + active_lease_id.clone(), + HlsPlaybackFamilyKey::new("soft-user", "client"), + proxy_session_id.clone(), + "soft-user".to_string(), + "soft-session-token".to_string(), + 1, + "pending-expiry-stream".to_string(), + 123, + now_ms, + 60_000, + ) + .with_origin_acquire_policy(ConnectionKind::Soft, 20), + ) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &active_lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 60_000, valid_window_ms: 60_000 }, + ) + .await + .is_activated()); + + app_state + .hls_proxy + .handle_lifecycle_event( + &app_state.active_users, + &app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::AccessLeaseValidity { + lease_id: lease_id.clone(), + proxy_session_id: proxy_session_id.clone(), + }, + due_at_ms: now_ms.saturating_add(1), + }, + now_ms.saturating_add(2), + ) + .await; + + assert_eq!( + app_state.hls_proxy.access_leases().write().await.lease_state(&lease_id, now_ms.saturating_add(2)), + None + ); + let session = session.read().await; + assert_eq!(session.activity.active_access_lease_count, 1); + let effective_policy = session.effective_origin_acquire_policy_or_default(); + assert_eq!(effective_policy.connection_kind, ConnectionKind::Soft); + assert_eq!(effective_policy.priority, 20); +} + +#[tokio::test] +async fn hls_lifecycle_session_idle_timer_removes_idle_session() { + let mut hls_dto = HlsCacheConfigDto { session_idle_timeout: shared::model::Secs::new(1), ..Default::default() }; + hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; + let hls_config = HlsCacheConfig::from(&hls_dto); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); + let now_ms = super::current_time_millis(); + let key = HlsSessionKey::new(1, "expired-session"); + let (session, _) = + app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms.saturating_sub(2_000)).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let lease_id = HlsAccessLeaseId("session-idle-cleanup-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "expired-session".to_string(), + 123, + now_ms, + 60_000, + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 30_000, valid_window_ms: 60_000 }, + ) + .await + .is_activated()); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); + assert_eq!(app_state.hls_proxy.segment_repair().stats().await.windows, 1); + + app_state + .hls_proxy + .handle_lifecycle_event( + &app_state.active_users, + &app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::SessionIdle { proxy_session_id: proxy_session_id.clone() }, + due_at_ms: now_ms.saturating_sub(1_000), + }, + now_ms, + ) + .await; + + assert!(app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.is_none()); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 0); + let repair_after = app_state.hls_proxy.segment_repair().stats().await; + assert_eq!(repair_after.windows, 0); + assert_eq!(repair_after.generations, 0); +} + +#[tokio::test] +async fn hls_gc_session_removal_cleans_access_leases_and_repair_state() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut hls_dto = HlsCacheConfigDto { + cache_path: Some(temp_dir.path().to_string_lossy().into_owned()), + session_idle_timeout: shared::model::Secs::new(1), + ..Default::default() + }; + hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; + let hls_config = HlsCacheConfig::from(&hls_dto); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); + let now_ms = super::current_time_millis(); + let key = HlsSessionKey::new(1, "gc-cleanup-session"); + let (session, _) = + app_state.hls_proxy.get_or_create_session_with_outcome(key, b"secret", now_ms.saturating_sub(2_000)).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let lease_id = HlsAccessLeaseId("gc-cleanup-lease".to_string()); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "gc-cleanup-session".to_string(), + 123, + now_ms, + 60_000, + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: 30_000, valid_window_ms: 60_000 }, + ) + .await + .is_activated()); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); + assert_eq!(app_state.hls_proxy.segment_repair().stats().await.windows, 1); + + let report = app_state.hls_proxy.run_garbage_collection_once(now_ms).await.expect("gc should run"); + + assert_eq!(report.sessions_deleted, 1); + assert!(app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.is_none()); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 0); + let repair_after = app_state.hls_proxy.segment_repair().stats().await; + assert_eq!(repair_after.windows, 0); + assert_eq!(repair_after.generations, 0); +} + +fn test_app_state() -> Arc { test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::new())) } + +#[tokio::test] +async fn lease_snapshot_limit_rejection_is_controlled_and_observable() { + let app_state = test_app_state(); + let proxy_session_id = ProxySessionId("snapshot-limit-session".to_string()); + let access_lease_id = HlsAccessLeaseId("snapshot-limit-lease".to_string()); + let oversized_uri = "x".repeat(MAX_HLS_MANIFEST_BYTES + 1); + let body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\n{oversized_uri}\n"); + let derivation = super::derive_hls_lease_manifest_snapshot( + &super::HlsLeaseManifestSnapshotInput::TransientPassthrough { + materialized_body: &body, + source_commit_identity: HlsManifestCommitIdentity::committed(1, 1), + finalized_manifest_generation: None, + }, + 2, + ); + + assert!(super::observe_hls_lease_manifest_snapshot_derivation( + &app_state, + &proxy_session_id, + &access_lease_id, + derivation, + ) + .is_err()); + assert_eq!(app_state.hls_proxy.metrics().snapshot().manifest_limit_rejections, 1); +} + +fn test_beast_hls_proxy(cache_path: &std::path::Path) -> Arc { + let config = HlsCacheConfig::from(&HlsCacheConfigDto { + cache_path: Some(cache_path.to_string_lossy().into_owned()), + strip: HlsStripConfigDto { mode: HlsStripMode::Segments, value: 3 }, + max_segments_prefetch: 6, + manifest_recovery_burst: HlsManifestRecoveryBurstConfigDto { level: HlsManifestRecoveryBurstLevel::Beast }, + ..HlsCacheConfigDto::default() + }); + Arc::new(HlsProxyManager::from_hls_cache_config(Some(&config))) +} + +fn disable_custom_stream_response(app_state: &Arc) { + app_state.app_config.config.store(Arc::new(Config { custom_stream_response_enabled: false, ..Default::default() })); +} + +fn enable_provider_exhausted_custom_response(app_state: &Arc) { + app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { + channel_unavailable: None, + user_connections_exhausted: None, + provider_connections_exhausted: Some(test_custom_video_buffer()), + low_priority_preempted: None, + user_account_expired: None, + panel_api_provisioning: None, + hls_session_or_lease_expired: None, + panel_api_provisioning_hls_segments: Vec::new(), + }))); +} + +fn enable_runtime_policy_custom_responses(app_state: &Arc) { + app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { + channel_unavailable: None, + user_connections_exhausted: Some(TransportStreamBuffer::new( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/hls/user_connections_exhausted.ts" + )) + .to_vec(), + )), + provider_connections_exhausted: Some(TransportStreamBuffer::new( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/hls/provider_connections_exhausted.ts" + )) + .to_vec(), + )), + low_priority_preempted: Some(TransportStreamBuffer::new( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/low_priority_preempted.ts")) + .to_vec(), + )), + user_account_expired: None, + panel_api_provisioning: None, + hls_session_or_lease_expired: None, + panel_api_provisioning_hls_segments: Vec::new(), + }))); +} + +fn enable_channel_unavailable_custom_response(app_state: &Arc) { + let config = app_state.app_config.config.load(); + app_state + .app_config + .config + .store(Arc::new(Config { custom_stream_response_enabled: true, ..config.as_ref().clone() })); + app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { + channel_unavailable: Some(test_custom_video_buffer()), + user_connections_exhausted: None, + provider_connections_exhausted: None, + low_priority_preempted: None, + user_account_expired: None, + panel_api_provisioning: None, + hls_session_or_lease_expired: None, + panel_api_provisioning_hls_segments: Vec::new(), + }))); +} + +fn test_custom_video_buffer() -> TransportStreamBuffer { + TransportStreamBuffer::new( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")).to_vec(), + ) +} + +fn enable_hls_provisioning_custom_response(app_state: &Arc) { + let mut ts_packet = vec![0_u8; 188]; + ts_packet[0] = 0x47; + let provisioning_segments = (0..6) + .map(|index| { + let mut packet = ts_packet.clone(); + packet[1] = u8::try_from(index).expect("test index fits"); + TransportStreamBuffer::new(packet) + }) + .collect(); + app_state.app_config.custom_stream_response.store(Some(Arc::new(CustomStreamResponse { + channel_unavailable: None, + user_connections_exhausted: None, + provider_connections_exhausted: None, + low_priority_preempted: None, + user_account_expired: None, + panel_api_provisioning: None, + hls_session_or_lease_expired: None, + panel_api_provisioning_hls_segments: provisioning_segments, + }))); +} + +fn test_app_state_with_inputs(inputs: Vec>) -> Arc { + test_app_state_with_hls_proxy_and_inputs(Arc::new(HlsProxyManager::new()), inputs) +} + +fn enable_hls_cache(app_state: &Arc) { + let config = Config { + reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { + hls_cache: Some(HlsCacheConfigDto::default()), + ..Default::default() + })), + ..Default::default() + }; + app_state.app_config.config.store(Arc::new(config)); +} + +fn test_app_state_with_hls_proxy(hls_proxy: Arc) -> Arc { + test_app_state_with_hls_proxy_and_inputs(hls_proxy, Vec::new()) +} + +fn test_app_state_with_hls_proxy_and_inputs( + hls_proxy: Arc, + inputs: Vec>, +) -> Arc { + let app_config = test_app_config(); + if !inputs.is_empty() { + app_config.sources.store(Arc::new(SourcesConfig { + batch_files: vec![], + provider: vec![], + group_lookup: build_group_lookup(&inputs), + inputs, + sources: vec![], + templates: None, + })); + } + let event_manager = Arc::new(EventManager::new()); + let active_provider = Arc::new(ActiveProviderManager::new(&app_config, &event_manager)); + let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); + active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager)); + + let geoip = Arc::new(ArcSwapOption::::default()); + let config = app_config.config.load(); + let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + let connection_manager = + Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager, None)); + let cancel_tokens = CancelTokens::default(); + let metadata_manager = Arc::new(MetadataUpdateManager::new(cancel_tokens.metadata.clone())); + let (manual_update_sender, _) = mpsc::channel::(1); + + Arc::new(AppState { + forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets { + enabled: false, + inputs: Vec::new(), + targets: Vec::new(), + target_names: Vec::new(), + })), + app_config, + http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + http_client_no_redirect: Arc::new(ArcSwap::from_pointee( + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("no-redirect client builds"), + )), + public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), + downloads: Arc::new(crate::api::model::DownloadQueue::new()), + cache: Arc::new(ArcSwapOption::default()), + shared_stream_manager, + hls_proxy, + hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), + active_users, + active_provider, + connection_manager, + event_manager, + cancel_tokens: Arc::new(ArcSwap::from_pointee(cancel_tokens)), + playlists: Arc::new(PlaylistStorageState::new()), + geoip, + update_guard: UpdateGuard::new(), + metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), + manual_update_sender, + }) +} + +async fn create_bound_hls_test_session( + app_state: &Arc, + input: &ConfigInput, + stream_ref: &str, + account_name: &str, + now_ms: u64, +) -> HlsSessionHandle { + let origin_source = super::build_hls_origin_source(input, stream_ref); + let session_key = origin_source.session_key(); + let (session, _) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key, + origin_source, + &app_state.get_encrypt_secret(), + now_ms, + ) + .await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + { + let mut session_guard = session.write().await; + session_guard.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::clone(&input.name), + Arc::from(account_name), + &proxy_session_id, + now_ms, + )); + } + session +} + +async fn create_unbound_hls_test_session( + app_state: &Arc, + input: &ConfigInput, + stream_ref: &str, + now_ms: u64, +) -> HlsSessionHandle { + let origin_source = super::build_hls_origin_source(input, stream_ref); + let session_key = origin_source.session_key(); + app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key, + origin_source, + &app_state.get_encrypt_secret(), + now_ms, + ) + .await + .0 +} + +fn test_hls_origin_io_context(app_state: &Arc) -> HlsOriginIoContext { + HlsOriginIoContext { + ctx: app_state.hls_ctx(), + client_addr: test_fingerprint().addr, + allow_grace: false, + priority: 0, + connection_kind: ConnectionKind::Normal, + reservation_ttl_secs: 60, + preacquired_provider_handle: None, + started_generation: None, + } +} + +fn single_hls_provider_input(name: &str) -> ConfigInput { + ConfigInput { + id: 1, + name: Arc::from(name), + input_type: InputType::Xtream, + url: "http://account.example.com".to_string(), + username: Some("account-user".to_string()), + password: Some("account-pass".to_string()), + enabled: true, + priority: 0, + max_connections: 1, + ..ConfigInput::default() + } +} + +fn overlap_provider_input() -> ConfigInput { + ConfigInput { + id: 1, + name: Arc::from("overlap-input"), + input_type: InputType::Xtream, + url: "http://root.example.com".to_string(), + username: Some("root-user".to_string()), + password: Some("root-pass".to_string()), + enabled: true, + priority: 10, + max_connections: 1, + aliases: Some(vec![crate::model::ConfigInputAlias { + id: 2, + name: Arc::from("account-a"), + url: "http://account.example.com".to_string(), + username: Some("account-user".to_string()), + password: Some("account-pass".to_string()), + priority: 0, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + } +} + +#[tokio::test] +async fn hls_origin_account_io_lease_allows_parallel_same_session_origin_work() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let binding = session.read().await.origin_account_binding.clone().expect("binding exists"); + let origin_io = test_hls_origin_io_context(&app_state); + + let first = begin_hls_origin_account_io(&origin_io, &session, &binding) + .await + .expect("first same-session origin io acquires provider account"); + wait_for_provider_connection_count(&app_state, 1).await; + let second = begin_hls_origin_account_io(&origin_io, &session, &binding) + .await + .expect("second same-session origin io joins session lease"); + wait_for_provider_connection_count(&app_state, 1).await; + assert_eq!( + session.read().await.origin_account_io_lease.as_ref().expect("session provider lease exists").active_io_count, + 2 + ); + + finish_hls_origin_account_io(&origin_io, &session, first, true).await; + wait_for_provider_connection_count(&app_state, 1).await; + assert_eq!( + session + .read() + .await + .origin_account_io_lease + .as_ref() + .expect("session provider lease remains while second io is active") + .active_io_count, + 1 + ); + + finish_hls_origin_account_io(&origin_io, &session, second, true).await; + wait_for_provider_connection_count(&app_state, 0).await; + assert!( + app_state + .active_provider + .is_provider_reserved_for_other_session(&binding.account_name, Some("other-hls-session")) + .await + ); +} + +#[tokio::test] +async fn hls_origin_account_io_lease_blocks_other_hls_sessions_for_same_account() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let first_session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let second_session = create_bound_hls_test_session(&app_state, &input, "67890", "account-a", 1_000).await; + let first_binding = first_session.read().await.origin_account_binding.clone().expect("binding exists"); + let second_binding = second_session.read().await.origin_account_binding.clone().expect("binding exists"); + let origin_io = test_hls_origin_io_context(&app_state); + + let first = begin_hls_origin_account_io(&origin_io, &first_session, &first_binding) + .await + .expect("first session acquires account"); + wait_for_provider_connection_count(&app_state, 1).await; + + assert!(begin_hls_origin_account_io(&origin_io, &second_session, &second_binding).await.is_err()); + wait_for_provider_connection_count(&app_state, 1).await; + + finish_hls_origin_account_io(&origin_io, &first_session, first, true).await; + wait_for_provider_connection_count(&app_state, 0).await; +} + +#[tokio::test] +async fn transient_origin_binding_requires_runtime_prepare_for_missing_or_detached_account() { + let input = single_hls_provider_input("known-account"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let proxy_session_id = ProxySessionId("shared-hls-session".to_string()); + let known_binding = + HlsOriginAccountBinding::new(Arc::clone(&input.name), Arc::clone(&input.name), &proxy_session_id, 1_000); + let missing_binding = + HlsOriginAccountBinding::new(Arc::clone(&input.name), Arc::from("removed-account"), &proxy_session_id, 1_000); + let mut detached_binding = known_binding.clone(); + detached_binding.detach(HlsOriginAccountDetachedReason::AccountMissingOrExpired, 2_000); + + let hls_ctx = app_state.hls_ctx(); + assert!(!super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &known_binding)); + assert!(super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &missing_binding)); + assert!(super::hls_transient_origin_binding_requires_runtime_prepare(&hls_ctx, &detached_binding)); +} + +#[tokio::test] +async fn hls_account_overlap_selects_soft_candidate_but_not_hard_active() { + let app_state = test_app_state(); + let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; + let session = create_bound_hls_test_session(&app_state, &input, "old", "account-a", 1_000).await; + { + let mut session = session.write().await; + session.target_duration = Some(10); + session.mark_authorized_media_access(1_000); + } + let new_proxy_session_id = ProxySessionId("new-session".to_string()); + + let hard_candidate = + super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 5_000).await; + assert!(hard_candidate.is_none(), "hard-active sessions must not be overbooked"); + + let delayed_candidate = + super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 12_000).await; + assert!(delayed_candidate.is_none(), "soft-active candidate must respect the dynamic overlap delay"); + + let soft_candidate = super::find_hls_account_overlap_candidate(&app_state, &input, &new_proxy_session_id, 21_000) + .await + .expect("soft-active session can be overbooked"); + assert_eq!(soft_candidate.account_name.as_ref(), "account-a"); + assert_eq!(soft_candidate.last_media_at_ms, 1_000); + assert_eq!(soft_candidate.soft_overlap_eligible_at_ms, 21_000); + assert_eq!(soft_candidate.soft_overlap_delay_ms, 20_000); + assert_eq!(soft_candidate.reclaim_until_ms, 31_000); +} + +#[test] +fn hls_soft_overlap_delay_scales_with_tuliprox_target_pressure() { + assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 1, 1), 20_000); + assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 3, 2), 15_000); + assert_eq!(super::hls_soft_overlap_delay_ms(10_000, 4, 2), 10_000); +} + +#[tokio::test] +async fn hls_account_overlap_reclaim_preempts_speculative_session() { + let app_state = test_app_state(); + let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; + let winner = create_bound_hls_test_session(&app_state, &input, "winner", "account-a", 1_000).await; + let loser = create_bound_hls_test_session(&app_state, &input, "loser", "account-a", 1_000).await; + let winner_proxy_session_id = winner.read().await.proxy_session_id.clone(); + let loser_proxy_session_id = loser.read().await.proxy_session_id.clone(); + { + let mut loser = loser.write().await; + loser.origin_account_binding = Some(HlsOriginAccountBinding::speculative_from( + Arc::clone(&input.name), + Arc::from("account-a"), + &loser_proxy_session_id, + winner_proxy_session_id.clone(), + 20_000, + 2_000, + )); + } + let loser_generation = loser.read().await.activity.origin_work_generation; + + super::reclaim_hls_account_overlap_if_needed(&app_state, &winner, 10_000).await; + + assert!(app_state.hls_proxy.is_account_overlap_cooling_down(&input.name, &Arc::from("account-a"), 10_000).await); + assert!(!app_state.hls_proxy.is_account_overlap_cooling_down(&input.name, &Arc::from("account-a"), 25_000).await); + let loser_binding_mode = loser.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); + assert!(matches!( + loser_binding_mode, + HlsOriginAccountBindingMode::Detached { reason: HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, .. } + )); + assert_eq!(loser.read().await.activity.origin_work_generation, loser_generation + 1); + let winner_binding_mode = winner.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); + assert!(matches!(winner_binding_mode, HlsOriginAccountBindingMode::Active)); +} + +#[tokio::test] +async fn hls_account_overlap_promotes_speculative_session_after_soft_window() { + let app_state = test_app_state(); + let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; + let displaced = create_bound_hls_test_session(&app_state, &input, "displaced", "account-a", 1_000).await; + let promoted = create_bound_hls_test_session(&app_state, &input, "promoted", "account-a", 1_000).await; + let displaced_proxy_session_id = displaced.read().await.proxy_session_id.clone(); + let promoted_proxy_session_id = promoted.read().await.proxy_session_id.clone(); + { + let mut promoted = promoted.write().await; + promoted.origin_account_binding = Some(HlsOriginAccountBinding::speculative_from( + Arc::clone(&input.name), + Arc::from("account-a"), + &promoted_proxy_session_id, + displaced_proxy_session_id, + 20_000, + 2_000, + )); + } + let displaced_generation = displaced.read().await.activity.origin_work_generation; + + super::promote_elapsed_hls_account_overlaps(&app_state, 20_001).await; + + let displaced_binding_mode = displaced.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); + assert!(matches!( + displaced_binding_mode, + HlsOriginAccountBindingMode::Detached { reason: HlsOriginAccountDetachedReason::SoftWindowElapsed, .. } + )); + assert_eq!(displaced.read().await.activity.origin_work_generation, displaced_generation + 1); + let promoted_binding_mode = promoted.read().await.origin_account_binding.as_ref().unwrap().binding_mode.clone(); + assert!(matches!(promoted_binding_mode, HlsOriginAccountBindingMode::Active)); +} + +#[tokio::test] +async fn hls_account_binding_soft_expiry_retains_session_and_reacquires_on_authorized_manifest_work() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + { + let mut session = session.write().await; + session.target_duration = Some(1); + session.mark_authorized_media_access(1_000); + } + let old_generation = session.read().await.activity.origin_work_generation; + + super::detach_unprotected_hls_origin_account_bindings(&app_state, 4_001).await; + + { + let session = session.read().await; + let binding = session.origin_account_binding.as_ref().expect("detached binding is retained"); + assert!(matches!( + binding.binding_mode, + HlsOriginAccountBindingMode::Detached { reason: HlsOriginAccountDetachedReason::SoftWindowElapsed, .. } + )); + assert_eq!(session.activity.origin_work_generation, old_generation + 1); + } + + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let prepared_origin = super::prepare_hls_origin_runtime( + &app_state, + &session, + &input, + "http://root.example.com/live/root-user/root-pass/12345.m3u8", + "http://root.example.com/live/root-user/root-pass/12345.m3u8", + &proxy_session_id, + &test_fingerprint(), + ConnectionKind::Normal, + 0, + super::HlsOriginWorkKind::Manifest, + super::HlsOriginWorkClass::ManifestInteractive, + 4_100, + ) + .await + .expect("authorized origin work can reacquire a detached binding"); + + let binding = + prepared_origin.origin_account_binding_to_store.as_ref().expect("new binding should be stored by caller"); + assert_eq!(binding.account_name.as_ref(), "account-a"); + assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); + assert_eq!(prepared_origin.fetch_url, "http://account.example.com/live/account-user/account-pass/12345.m3u8"); + app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; +} + +#[tokio::test] +async fn hls_origin_runtime_uses_soft_overlap_before_grace_for_interactive_work() { + let input = single_hls_provider_input("soft-overlap-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let old_session = create_bound_hls_test_session(&app_state, &input, "old", input.name.as_ref(), 1_000).await; + let old_proxy_session_id = old_session.read().await.proxy_session_id.clone(); + { + let mut session = old_session.write().await; + session.target_duration = Some(10); + session.mark_authorized_media_access(1_000); + } + let old_binding = old_session.read().await.origin_account_binding.clone().expect("binding exists"); + app_state + .active_provider + .refresh_provider_reservation(&old_binding.account_name, &old_binding.session_owner, 60) + .await; + + let new_session = create_unbound_hls_test_session(&app_state, &input, "new", 12_000).await; + let new_proxy_session_id = new_session.read().await.proxy_session_id.clone(); + let prepared_origin = super::prepare_hls_origin_runtime( + &app_state, + &new_session, + &input, + "http://account.example.com/live/account-user/account-pass/new.m3u8", + "http://account.example.com/live/account-user/account-pass/new.m3u8", + &new_proxy_session_id, + &test_fingerprint_with_addr(test_addr_with_port(55201)), + ConnectionKind::Normal, + 0, + super::HlsOriginWorkKind::Manifest, + super::HlsOriginWorkClass::ManifestInteractive, + 21_000, + ) + .await + .expect("interactive work should use soft-active overlap before grace"); + + let binding = + prepared_origin.origin_account_binding_to_store.as_ref().expect("speculative binding should be prepared"); + assert_eq!(binding.account_name, input.name); + assert!(matches!( + &binding.binding_mode, + HlsOriginAccountBindingMode::Speculative { + displaced_proxy_session_id, + .. + } if displaced_proxy_session_id == &old_proxy_session_id + )); + assert!(matches!( + prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), + Some(super::ProviderAllocation::Available(_)) + )); + + app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; +} + +#[tokio::test] +async fn hls_origin_runtime_normal_policy_preempts_active_soft_hls_binding() { + let input = single_hls_provider_input("policy-preempt-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let soft_session = create_bound_hls_test_session(&app_state, &input, "soft", input.name.as_ref(), 1_000).await; + let soft_generation = { + let mut session = soft_session.write().await; + session.target_duration = Some(10); + session.mark_authorized_media_access(10_000); + session.reconcile_effective_origin_acquire_policy( + Some(HlsEffectiveOriginAcquirePolicy::new(ConnectionKind::Soft, 0, 10_000)), + 10_000, + ); + session.activity.origin_work_generation + }; + let soft_binding = soft_session.read().await.origin_account_binding.clone().expect("soft binding exists"); + app_state + .active_provider + .refresh_provider_reservation(&soft_binding.account_name, &soft_binding.session_owner, 60) + .await; + + let normal_session = create_unbound_hls_test_session(&app_state, &input, "normal", 10_500).await; + let normal_proxy_session_id = normal_session.read().await.proxy_session_id.clone(); + let prepared_origin = super::prepare_hls_origin_runtime( + &app_state, + &normal_session, + &input, + "http://account.example.com/live/account-user/account-pass/normal.m3u8", + "http://account.example.com/live/account-user/account-pass/normal.m3u8", + &normal_proxy_session_id, + &test_fingerprint_with_addr(test_addr_with_port(55231)), + ConnectionKind::Normal, + 0, + super::HlsOriginWorkKind::Manifest, + super::HlsOriginWorkClass::ManifestInteractive, + 10_500, + ) + .await + .expect("normal HLS policy should preempt active soft HLS binding"); + + let new_binding = prepared_origin + .origin_account_binding_to_store + .as_ref() + .expect("preempting session should receive active binding"); + assert_eq!(new_binding.account_name, input.name); + assert!(matches!(new_binding.binding_mode, HlsOriginAccountBindingMode::Active)); + assert!(matches!( + prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), + Some(super::ProviderAllocation::Available(_)) + )); + let soft_session = soft_session.read().await; + assert_eq!(soft_session.activity.origin_work_generation, soft_generation + 1); + assert!(matches!( + soft_session.origin_account_binding.as_ref().map(|binding| &binding.binding_mode), + Some(HlsOriginAccountBindingMode::Detached { + reason: HlsOriginAccountDetachedReason::PreemptedByHigherPriority, + .. + }) + )); + drop(soft_session); + + app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; +} + +#[tokio::test] +async fn preempted_origin_runtime_commits_low_priority_tail_without_redirect_or_origin_fetch() { + let fixture = runtime_policy_endpoint_fixture(true).await; + let input = single_hls_provider_input("runtime-preempted-input"); + let session = fixture + .app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&fixture.proxy_session_id) + .await + .expect("runtime policy session"); + let now_ms = super::current_time_millis(); + let mut binding = HlsOriginAccountBinding::new( + Arc::clone(&input.name), + Arc::from("preempted-account"), + &fixture.proxy_session_id, + now_ms, + ); + binding.detach(HlsOriginAccountDetachedReason::PreemptedByHigherPriority, now_ms); + session.write().await.replace_origin_account_binding(Some(binding)); + let origin_refresh_before = session.read().await.origin_refresh.clone(); + let origin = super::HlsCacheManifestOrigin { + raw_request_url: "http://account.example.com/live/account-user/account-pass/12345.m3u8", + session_entry_url: super::HlsOriginEntryUrl::direct_http( + "http://account.example.com/live/account-user/account-pass/12345.m3u8", + ), + input: &input, + origin_source: super::build_hls_origin_source(&input, "12345"), + }; + let context = test_hls_access_context(fixture.proxy_session_id.clone(), fixture.lease_id.clone()); + + let result = super::prepare_hls_canonical_manifest_origin_runtime( + &fixture.app_state, + &session, + &context, + &origin, + &fixture.proxy_session_id, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &test_fingerprint(), + None, + now_ms, + ) + .await; + let Err(response) = result else { + panic!("detached origin binding must resolve to the lease-bound policy tail"); + }; + + assert!(!response.headers().contains_key(header::LOCATION)); + let plan = wait_for_runtime_policy_terminal_plan(&fixture).await; + assert_eq!(plan.reason, HlsRuntimeCustomTailReason::LowPriorityPreempted); + assert_eq!(plan.segment_duration_ms, 10_027); + assert_eq!(session.read().await.origin_refresh, origin_refresh_before); + let replay = get_response(Arc::clone(&fixture.app_state), &fixture.manifest_uri, None).await; + assert_eq!(replay.status(), StatusCode::OK); + assert!(!replay.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(replay).await.to_vec()).expect("preemption manifest utf8"); + assert!(body.contains("/terminal/")); + assert!(!body.contains("/cvs/hls/")); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); +} + +#[tokio::test] +async fn hls_origin_policy_preemption_rejects_soft_request_against_active_normal_binding() { + let input = single_hls_provider_input("policy-no-preempt-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let normal_session = create_bound_hls_test_session(&app_state, &input, "normal", input.name.as_ref(), 1_000).await; + { + let mut session = normal_session.write().await; + session.target_duration = Some(10); + session.mark_authorized_media_access(10_000); + session.reconcile_effective_origin_acquire_policy( + Some(HlsEffectiveOriginAcquirePolicy::new(ConnectionKind::Normal, 0, 10_000)), + 10_000, + ); + } + let normal_binding = normal_session.read().await.origin_account_binding.clone().expect("normal binding exists"); + app_state + .active_provider + .refresh_provider_reservation(&normal_binding.account_name, &normal_binding.session_owner, 60) + .await; + let soft_session = create_unbound_hls_test_session(&app_state, &input, "soft", 10_500).await; + let soft_proxy_session_id = soft_session.read().await.proxy_session_id.clone(); + + let result = super::prepare_hls_origin_policy_preempt_runtime( + &app_state, + &soft_session, + &input, + "http://account.example.com/live/account-user/account-pass/soft.m3u8", + "http://account.example.com/live/account-user/account-pass/soft.m3u8", + &soft_proxy_session_id, + &test_fingerprint_with_addr(test_addr_with_port(55232)), + ConnectionKind::Soft, + -100, + 10_500, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + normal_session.read().await.origin_account_binding.as_ref().map(|binding| &binding.binding_mode), + Some(HlsOriginAccountBindingMode::Active) + )); + assert!( + app_state + .active_provider + .is_provider_reserved_for_other_session(&normal_binding.account_name, Some("unrelated-session")) + .await + ); +} + +#[tokio::test] +async fn hls_origin_runtime_uses_grace_as_interactive_fallback_after_overlap_fails() { + let input = single_hls_provider_input("grace-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let occupied = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &test_addr_with_port(55211), + false, + 0, + ConnectionKind::Normal, + Some("external-owner"), + ) + .await + .expect("test should occupy the only provider account"); + let session = create_unbound_hls_test_session(&app_state, &input, "12345", 2_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + + let prepared_origin = super::prepare_hls_origin_runtime( + &app_state, + &session, + &input, + "http://account.example.com/live/account-user/account-pass/12345.m3u8", + "http://account.example.com/live/account-user/account-pass/12345.m3u8", + &proxy_session_id, + &test_fingerprint_with_addr(test_addr_with_port(55212)), + ConnectionKind::Normal, + 0, + super::HlsOriginWorkKind::Manifest, + super::HlsOriginWorkClass::ManifestInteractive, + 2_000, + ) + .await + .expect("interactive work can use grace when normal acquire and overlap fail"); + + assert!(matches!( + prepared_origin.preacquired_origin_account_handle.as_ref().map(|handle| &handle.allocation), + Some(super::ProviderAllocation::GracePeriod(_)) + )); + assert_eq!( + prepared_origin + .origin_account_binding_to_store + .as_ref() + .expect("grace binding should still bind the selected account") + .account_name + .as_ref(), + input.name.as_ref() + ); + + app_state.connection_manager.release_provider_handle(prepared_origin.preacquired_origin_account_handle).await; + app_state.connection_manager.release_provider_handle(Some(occupied)).await; +} + +#[tokio::test] +async fn hls_provider_exhausted_without_provisioning_returns_custom_manifest() { + let input = single_hls_provider_input("provider-exhausted-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + store_test_sources_with_target( + &app_state, + input.clone(), + ConfigTarget::from(&ConfigTargetDto { id: 1, name: "default".to_string(), ..Default::default() }), + ); + enable_provider_exhausted_custom_response(&app_state); + let session = create_unbound_hls_test_session(&app_state, &input, "provider-exhausted-session", 1_000).await; + let access_lease_id = HlsAccessLeaseId("provider-exhausted-lease".to_string()); + let proxy_session_id = session.read().await.proxy_session_id.clone(); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + let strip = app_state.hls_proxy.strip(); + + let response = super::hls_shared_provisioning_or_provider_exhausted_response( + &app_state, + &session, + "hls-user", + &input, + 59, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + let manifest = String::from_utf8(body.to_vec()).expect("manifest is utf8"); + assert!(manifest.contains("#EXTM3U")); + assert!(manifest.contains(&format!("/cvs/hls/{}/", access_lease_id.0))); + assert!(!manifest.contains("/hls-user/")); + assert!(!manifest.contains("/hls-pass/")); + assert!(!manifest.contains("/provider_connections_exhausted/")); +} + +#[tokio::test] +async fn hls_provider_exhausted_grace_hold_waits_for_grace_period_before_retry() { + let input = single_hls_provider_input("provider-grace-hold-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + app_state.app_config.config.store(Arc::new(Config { + reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { + stream: Some(StreamConfigDto { + grace_period_millis: 20, + grace_period_timeout_secs: 10, + grace_period_hold_stream: true, + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + })); + let session = create_unbound_hls_test_session(&app_state, &input, "provider-grace-session", 1_000).await; + let access_lease_id = HlsAccessLeaseId("provider-grace-lease".to_string()); + let strip = app_state.hls_proxy.strip(); + let resolution = super::hls_provider_connections_exhausted_manifest_resolution( + &app_state, + &session, + "hls-user", + &input, + 59, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + true, + ); + + let resolution = + tokio::time::timeout(Duration::from_millis(500), resolution).await.expect("grace hold deadline should wake"); + + assert!(matches!(resolution, super::HlsProviderExhaustedResolution::RetryAcquire)); +} + +#[tokio::test] +async fn provider_grace_expiry_commits_lease_bound_provider_exhausted_tail() { + let fixture = runtime_policy_endpoint_fixture(true).await; + let input = single_hls_provider_input("runtime-provider-grace-input"); + store_test_sources_with_target( + &fixture.app_state, + input.clone(), + ConfigTarget::from(&ConfigTargetDto { id: 1, name: "default".to_string(), ..Default::default() }), + ); + let current = fixture.app_state.app_config.config.load(); + fixture.app_state.app_config.config.store(Arc::new(Config { + reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { + stream: Some(StreamConfigDto { + grace_period_millis: 20, + grace_period_timeout_secs: 10, + grace_period_hold_stream: true, + ..Default::default() + }), + ..Default::default() + })), + ..current.as_ref().clone() + })); + let strip = fixture.app_state.hls_proxy.strip(); + let session = fixture + .app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&fixture.proxy_session_id) + .await + .expect("runtime provider session"); + + let grace = super::hls_provider_connections_exhausted_manifest_resolution( + &fixture.app_state, + &session, + "hls-user", + &input, + 12345, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + true, + ) + .await; + assert!(matches!(grace, super::HlsProviderExhaustedResolution::RetryAcquire)); + + let response = super::hls_provider_connections_exhausted_manifest_resolution( + &fixture.app_state, + &session, + "hls-user", + &input, + 12345, + &fixture.lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + false, + ) + .await; + let super::HlsProviderExhaustedResolution::Response(response) = response else { + panic!("expired grace must resolve to a finite response"); + }; + assert!(!response.headers().contains_key(header::LOCATION)); + + let plan = wait_for_runtime_policy_terminal_plan(&fixture).await; + assert_eq!(plan.reason, HlsRuntimeCustomTailReason::ProviderConnectionsExhausted); + assert_eq!(plan.segment_duration_ms, 10_027); + let replay = super::hls_provider_connections_exhausted_manifest_resolution( + &fixture.app_state, + &session, + "hls-user", + &input, + 12345, + &fixture.lease_id, + HlsAccessLeaseState::Denied, + &strip, + None, + false, + ) + .await; + let super::HlsProviderExhaustedResolution::Response(replay) = replay else { + panic!("committed provider tail must replay"); + }; + assert_eq!(replay.status(), StatusCode::OK); + assert!(!replay.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(replay).await.to_vec()).expect("provider terminal manifest utf8"); + assert!(body.contains("/terminal/")); + assert!(!body.contains("/cvs/hls/")); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); +} + +#[tokio::test] +async fn shared_provisioning_timeline_manifest_uses_canonical_hls_session_segments() { + let input = single_hls_provider_input("shared-provisioning-timeline-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_provisioning_custom_response(&app_state); + let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; + let access_lease_id = HlsAccessLeaseId("timeline-lease".to_string()); + let proxy_session_id = session.read().await.proxy_session_id.clone(); + activate_test_hls_access_lease( + &app_state, + &proxy_session_id, + &access_lease_id.0, + super::current_time_millis(), + 60_000, + ) + .await; + let strip = app_state.hls_proxy.strip(); + + let response = super::hls_shared_provisioning_timeline_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + ) + .await + .expect("provisioning manifest should render"); + + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest is utf8"); + assert!(body.contains("#EXT-X-VERSION:7\n")); + assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS\n")); + assert!(body.contains("#EXT-X-TARGETDURATION:2\n")); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); + assert!(body.contains("#EXTINF:2.000,\n")); + assert!(!body.contains("#EXTINF:12.000,\n")); + assert!(body.contains("/hls/shared/live/")); + assert!(body.contains("/000000.ts?pseq=0")); + assert!(body.contains("/000001.ts?pseq=1")); + assert!(body.contains("/000002.ts?pseq=2")); + assert!(body.matches("#EXTINF:").count() <= 6); + assert!(!body.contains("/cvs/hls/")); + { + let session = session.read().await; + assert_eq!(session.proxy_next_seq, Some(3)); + assert_eq!(session.publishable_origin_head_proxy_seq, Some(0)); + assert_eq!(session.publishable_origin_tail_proxy_seq, Some(2)); + assert_eq!(session.segments.len(), 3); + } + + let response = super::hls_shared_provisioning_timeline_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + ) + .await + .expect("subsequent manifest should append one segment"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest is utf8"); + assert!(body.contains("/000003.ts?pseq=3")); + assert!(body.matches("#EXTINF:").count() <= 6); + assert_eq!(session.read().await.proxy_next_seq, Some(4)); +} + +#[tokio::test] +async fn stale_provisioning_segments_do_not_trigger_canonical_handoff() { + let input = single_hls_provider_input("stale-provisioning-handoff-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_provisioning_custom_response(&app_state); + let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; + let initial_lease_id = HlsAccessLeaseId("initial-provisioning-lease".to_string()); + let strip = app_state.hls_proxy.strip(); + + super::hls_shared_provisioning_timeline_manifest_response( + &app_state, + &session, + &initial_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + ) + .await + .expect("provisioning manifest should render local segments"); + { + let session_guard = session.read().await; + assert!(session_guard.segments.values().any(crate::api::model::is_hls_provisioning_segment)); + assert_eq!(session_guard.segments.len(), 3); + assert_eq!(session_guard.pending_handoff_discontinuity_sequence, None); + } + + let new_lease_id = HlsAccessLeaseId("new-playback-lease".to_string()); + let previous_rendered_at = super::maybe_mark_hls_provisioning_handoff_for_canonical_manifest( + &app_state, + &session, + &input, + 12345, + &new_lease_id, + 2_000, + ) + .await; + + assert_eq!(previous_rendered_at, None); + let session_guard = session.read().await; + assert_eq!(session_guard.segments.len(), 3); + assert_eq!(session_guard.pending_handoff_discontinuity_sequence, None); +} + +#[tokio::test] +async fn provisioning_handoff_finds_shared_session_by_input_stream_id_not_virtual_id() { + let input = single_hls_provider_input("origin-id-handoff-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + let origin_session = create_unbound_hls_test_session(&app_state, &input, "80510", 1_000).await; + let virtual_id_session = create_unbound_hls_test_session(&app_state, &input, "1001", 1_000).await; + let stream_identity = super::HlsEntryStreamIdentity::new(1001, "80510").expect("input stream identity"); + + assert!( + super::mark_hls_provisioning_handoff_discontinuity(&app_state, &input, &stream_identity, None, 2_000,).await + ); + + assert!(origin_session.read().await.pending_handoff_discontinuity_sequence.is_some()); + assert_eq!(virtual_id_session.read().await.pending_handoff_discontinuity_sequence, None); +} + +#[tokio::test] +async fn shared_provisioning_handoff_continues_proxy_sequence_for_origin_segments() { + let input = single_hls_provider_input("shared-provisioning-handoff-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_provisioning_custom_response(&app_state); + let session = create_unbound_hls_test_session(&app_state, &input, "12345", 1_000).await; + let access_lease_id = HlsAccessLeaseId("handoff-lease".to_string()); + let strip = app_state.hls_proxy.strip(); + super::hls_shared_provisioning_timeline_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + ) + .await + .expect("provisioning manifest should render"); + + let manifest = match parse_origin_media_manifest( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:4025\n#EXTINF:4.0,\n4025.ts\n#EXTINF:4.0,\n4026.ts\n#EXTINF:4.0,\n4027.ts\n", + "http://origin.example/live/stream.m3u8", + ) { + OriginManifestParseOutcome::Normal(manifest) => manifest, + OriginManifestParseOutcome::TransientPassthrough { reason } => { + panic!("expected normal manifest: {reason:?}") + } + }; + let rendered = { + let mut session_guard = session.write().await; + session_guard.mark_pending_handoff_discontinuity(0); + drop(session_guard); + assert!( + super::ensure_shared_hls_provisioning_handoff_gap(&app_state, &session, 2_000).await, + "handoff should append one gap segment" + ); + let mut session_guard = session.write().await; + session_guard.apply_origin_manifest(&manifest).expect("origin manifest should map"); + for proxy_seq in 4..=6 { + session_guard.segments.get_mut(&proxy_seq).expect("origin segment").status = + SegmentCacheStatus::Ready { content_length: 1024, ready_at_ms: 2_000 }; + } + session_guard.render_and_store_manifest(2_000).expect("handoff manifest should render") + }; + + assert!(rendered.body.contains("#EXT-X-MEDIA-SEQUENCE:1\n")); + assert!(rendered.body.contains("#EXT-X-TARGETDURATION:4\n")); + assert!(rendered.body.contains("/000002.ts?pseq=2")); + assert!(rendered.body.contains("/000004.ts")); + assert!(rendered.body.contains("/000005.ts")); + assert!(rendered.body.contains("/000006.ts")); + assert!(!rendered.body.contains("/004025.ts")); + let provisioning_tail = rendered.body.find("/000002.ts?pseq=2").expect("provisioning tail is rendered"); + let gap_tag = rendered.body.find("#EXT-X-GAP\n").expect("handoff gap tag is rendered"); + let gap_uri = rendered.body.find("/000003.ts?pseq=3").expect("handoff gap uri is rendered"); + let discontinuity = rendered + .body + .find("#EXT-X-DISCONTINUITY\n#EXTINF:4.000,\n/hls/shared/live/") + .expect("origin handoff discontinuity is rendered"); + let first_origin = rendered.body.find("/000004.ts").expect("first origin segment is rendered"); + assert!(provisioning_tail < gap_tag); + assert!(gap_tag < gap_uri); + assert!(gap_uri < discontinuity); + assert!(discontinuity < first_origin); +} + +#[tokio::test] +async fn hls_origin_runtime_background_skips_soft_overlap_and_grace() { + let input = single_hls_provider_input("background-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let old_session = create_bound_hls_test_session(&app_state, &input, "old", input.name.as_ref(), 1_000).await; + { + let mut session = old_session.write().await; + session.target_duration = Some(10); + session.mark_authorized_media_access(1_000); + } + let old_binding = old_session.read().await.origin_account_binding.clone().expect("binding exists"); + app_state + .active_provider + .refresh_provider_reservation(&old_binding.account_name, &old_binding.session_owner, 60) + .await; + let new_session = create_unbound_hls_test_session(&app_state, &input, "new", 12_000).await; + let new_proxy_session_id = new_session.read().await.proxy_session_id.clone(); + + let result = super::prepare_hls_origin_runtime( + &app_state, + &new_session, + &input, + "http://account.example.com/live/account-user/account-pass/new.m3u8", + "http://account.example.com/live/account-user/account-pass/new.m3u8", + &new_proxy_session_id, + &test_fingerprint_with_addr(test_addr_with_port(55221)), + ConnectionKind::Normal, + 0, + super::HlsOriginWorkKind::Segment, + super::HlsOriginWorkClass::Background, + 12_000, + ) + .await; + + assert_eq!( + result.err(), + Some(super::HlsOriginRuntimeAcquireError::NoAccountAvailable { + reason: super::HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted + }) + ); + let old_session = old_session.read().await; + assert!(matches!( + old_session.origin_account_binding.as_ref().expect("old binding remains").binding_mode, + HlsOriginAccountBindingMode::Active + )); + assert!(new_session.read().await.origin_account_binding.is_none()); +} + +#[test] +fn hls_detached_origin_binding_reclaimed_by_owner_maps_to_preempted_no_account_reason() { + let proxy_session_id = ProxySessionId("preempted-session".to_string()); + let mut binding = + HlsOriginAccountBinding::new(Arc::from("input-a"), Arc::from("account-a"), &proxy_session_id, 1_000); + binding.detach(HlsOriginAccountDetachedReason::ReclaimedByOriginalOwner, 2_000); + + assert_eq!( + super::hls_no_account_reason_for_binding(Some(&binding)), + super::HlsOriginRuntimeNoAccountReason::OriginBindingPreempted + ); +} + +#[test] +fn hls_detached_origin_binding_soft_window_elapsed_maps_to_exhausted_no_account_reason() { + let proxy_session_id = ProxySessionId("soft-window-session".to_string()); + let mut binding = + HlsOriginAccountBinding::new(Arc::from("input-a"), Arc::from("account-a"), &proxy_session_id, 1_000); + binding.detach(HlsOriginAccountDetachedReason::SoftWindowElapsed, 2_000); + + assert_eq!( + super::hls_no_account_reason_for_binding(Some(&binding)), + super::HlsOriginRuntimeNoAccountReason::ProviderConnectionsExhausted + ); +} + +#[tokio::test] +async fn hls_account_binding_without_media_activity_is_not_detached() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + { + let mut session = session.write().await; + session.target_duration = Some(12); + session.mark_authorized_manifest_access(1_000); + } + let old_generation = session.read().await.activity.origin_work_generation; + + super::detach_unprotected_hls_origin_account_bindings(&app_state, 60_000).await; + + let session = session.read().await; + let binding = session.origin_account_binding.as_ref().expect("binding remains"); + assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); + assert_eq!(session.activity.origin_work_generation, old_generation); + assert_eq!(session.activity.last_authorized_media_at_ms, None); + assert_eq!(session.account_overlap_timing().target_duration_ms, 12_000); +} + +#[tokio::test] +async fn hls_hard_manifest_failure_forces_next_fresh_commit() { + let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); + { + let mut session = session.write().await; + session.last_rendered_manifest = Some(RenderedManifest { + body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), + first_proxy_seq: 1, + last_proxy_seq: 1, + playlist_duration_ms: 4_000, + valid_until_ms: 5_000, + render_gap_segments: 0, + rendered_at_ms: 1_000, + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![1], + }); + session.require_fresh_manifest_commit(HlsFreshManifestRequiredReason::PreviousHardManifestFailure); + } + + assert_eq!( + super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, 2_000).await, + HlsManifestCommitRequirement::FreshCommitRequired { + reason: HlsFreshManifestRequiredReason::PreviousHardManifestFailure + } + ); +} + +#[tokio::test] +async fn hls_normal_expired_session_allows_committed_manifest_while_manifest_valid() { + let now_ms = 100_000; + let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); + { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.clone(); + session.target_duration = Some(10); + session.mark_authorized_media_access(1_000); + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::from("test-input"), + Arc::from("test-account"), + &proxy_session_id, + now_ms, + )); + session.last_rendered_manifest = Some(RenderedManifest { + body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), + first_proxy_seq: 1, + last_proxy_seq: 1, + playlist_duration_ms: 4_000, + valid_until_ms: now_ms.saturating_add(10_000), + render_gap_segments: 0, + rendered_at_ms: now_ms.saturating_sub(1_000), + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![1], + }); + } + + assert_eq!( + super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, now_ms).await, + HlsManifestCommitRequirement::CommittedManifestAllowed + ); +} + +#[tokio::test] +async fn hls_normal_expired_session_requires_fresh_commit_after_manifest_validity() { + let now_ms = 100_000; + let session = Arc::new(RwLock::new(HlsSession::new(HlsSessionKey::new(1, "12345"), b"secret", 0))); + { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.clone(); + session.target_duration = Some(10); + session.mark_authorized_media_access(1_000); + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::from("test-input"), + Arc::from("test-account"), + &proxy_session_id, + now_ms, + )); + session.last_rendered_manifest = Some(RenderedManifest { + body: "#EXTM3U\n#EXTINF:4.0,\n000001.ts\n".to_string(), + first_proxy_seq: 1, + last_proxy_seq: 1, + playlist_duration_ms: 4_000, + valid_until_ms: now_ms.saturating_sub(1), + render_gap_segments: 0, + rendered_at_ms: now_ms.saturating_sub(10_000), + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![1], + }); + } + + assert_eq!( + super::hls_manifest_commit_requirement(&session, HlsSessionStoreOutcome::Reused, None, now_ms).await, + HlsManifestCommitRequirement::FreshCommitRequired { + reason: HlsFreshManifestRequiredReason::ExpiredRevalidation + } + ); +} + +#[tokio::test] +async fn hls_ready_cache_hit_does_not_require_origin_reacquire_when_binding_is_detached() { + let app_state = test_app_state(); + let input = ConfigInput { id: 1, name: Arc::from("overlap-input"), ..ConfigInput::default() }; + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + { + let mut session = session.write().await; + session + .origin_account_binding + .as_mut() + .expect("binding exists") + .detach(HlsOriginAccountDetachedReason::Cleanup, 2_000); + session.segments.insert( + 123, + SegmentEntry { + origin_key: OriginSegmentKey { + origin_epoch: 0, + effective_host_id: 0, + host_local_sequence: 123, + host_local_index: 123, + }, + proxy_seq: 123, + duration_ms: 4_000, + proxy_file_ext: "ts".to_string(), + content_type: "video/mp2t".to_string(), + cache_key: SegmentCacheKey::new(proxy_session_id, 123, "ts"), + discontinuity_before: false, + program_date_time: None, + daterange_tags_before: Vec::new(), + origin_byte_range: None, + map_ref: None, + encryption: None, + origin_fetch_ref: Some(OriginSegmentFetchRef { + resolved_origin_url: "http://origin.example.com/123.ts".to_string(), + byte_range: None, + valid_until_ms: None, + }), + status: SegmentCacheStatus::Ready { content_length: 12, ready_at_ms: 1_000 }, + last_rendered_at_ms: None, + access: Arc::new(CacheAccessState::new()), + }, + ); + } + + let segment_file = HlsSegmentFile { proxy_seq: 123, extension: "ts".to_string() }; + assert!(!super::hls_segment_request_requires_origin_work(&session, &segment_file).await); + assert!(super::hls_origin_binding_needs_reacquire(&session).await); +} + +async fn activate_test_hls_access_lease( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_id: &str, + now_ms: u64, + ttl_ms: u64, +) { + let lease_id = HlsAccessLeaseId(lease_id.to_string()); + let valid_window_ms = ttl_ms.saturating_mul(10).max(ttl_ms); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + valid_window_ms, + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + proxy_session_id, + now_ms, + HlsAccessLeaseTiming { active_window_ms: ttl_ms, valid_window_ms }, + ) + .await + .is_activated()); +} + +async fn register_test_hls_stream_for_lease_release( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + provider: &str, +) { + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.max_connections = 1; + app_state + .active_users + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "hls-session-token", + virtual_id: 12345, + provider, + stream_url: "http://origin.example.com/live/user/pass/12345.m3u8", + addr: &test_addr(), + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + let mut stream_channel = + super::fallback_hls_cache_stream_channel(0, 12345, &session.read().await.origin_source, proxy_session_id); + stream_channel.shared = true; + stream_channel.shared_stream_id = Some(super::hls_cache_shared_stream_id(proxy_session_id)); + app_state + .connection_manager + .update_connection(crate::api::model::ConnectionParams { + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &test_fingerprint(), + provider: Arc::from(provider), + stream_channel: &stream_channel, + user_agent: std::borrow::Cow::Borrowed("test"), + session_token: Some("hls-session-token"), + }) + .await; +} + +fn test_segment_entry(proxy_session_id: &ProxySessionId, proxy_seq: u64, status: SegmentCacheStatus) -> SegmentEntry { + SegmentEntry { + origin_key: OriginSegmentKey { + origin_epoch: 0, + effective_host_id: 0, + host_local_sequence: proxy_seq, + host_local_index: u32::try_from(proxy_seq).unwrap_or(u32::MAX), + }, + proxy_seq, + duration_ms: 4_000, + proxy_file_ext: "ts".to_string(), + content_type: "video/mp2t".to_string(), + cache_key: SegmentCacheKey::new(proxy_session_id.clone(), proxy_seq, "ts"), + discontinuity_before: false, + program_date_time: None, + daterange_tags_before: Vec::new(), + origin_byte_range: None, + map_ref: None, + encryption: None, + origin_fetch_ref: Some(OriginSegmentFetchRef { + resolved_origin_url: format!("http://origin.example.com/{proxy_seq}.ts"), + byte_range: None, + valid_until_ms: None, + }), + status, + last_rendered_at_ms: None, + access: Arc::new(CacheAccessState::new()), + } +} + +#[tokio::test] +async fn hls_access_lease_idle_releases_user_but_keeps_origin_binding_and_queues() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let binding = session.read().await.origin_account_binding.clone().expect("binding exists"); + let account_name = Arc::from("account-a"); + app_state.active_provider.refresh_provider_reservation(&account_name, &binding.session_owner, 60).await; + assert!(app_state.active_provider.is_provider_reserved_for_other_session(&account_name, Some("other-owner")).await); + activate_test_hls_access_lease(&app_state, &proxy_session_id, "detach-lease", 1_000, 1_000).await; + register_test_hls_stream_for_lease_release(&app_state, &session, &proxy_session_id, input.name.as_ref()).await; + assert_eq!(app_state.active_users.active_streams().await.len(), 1); + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + 1_000, + ) + .await; + { + let mut session = session.write().await; + assert_eq!(session.activity.active_access_lease_count, 1); + session.segments.insert(1, test_segment_entry(&proxy_session_id, 1, SegmentCacheStatus::Discovered)); + session.queue_segment_fetch_candidate(1, SegmentFetchPriority::Prefetch, 1_100); + session.segments.insert( + 2, + test_segment_entry( + &proxy_session_id, + 2, + SegmentCacheStatus::Ready { content_length: 12, ready_at_ms: 1_000 }, + ), + ); + let map_id = ProxyMapId(1); + let mut map = MapEntry::new( + &proxy_session_id, + map_id, + OriginMapKey { + origin_epoch: 0, + resolved_origin_uri: "http://origin.example.com/init.mp4".to_string(), + byte_range: None, + }, + "mp4".to_string(), + ); + map.status = MapCacheStatus::Queued { queued_at_ms: 1_100 }; + session.maps.insert(map_id, map); + } + let old_generation = session.read().await.activity.origin_work_generation; + + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + 3_000, + ) + .await; + + { + let session = session.read().await; + assert_eq!(session.activity.active_access_lease_count, 0); + let binding = session.origin_account_binding.as_ref().expect("binding is retained"); + assert!(matches!(binding.binding_mode, HlsOriginAccountBindingMode::Active)); + assert_eq!(session.activity.origin_work_generation, old_generation); + assert!(!session.segment_prefetch_queue.is_empty()); + assert!(matches!( + session.segments.get(&1).expect("queued segment remains").status, + SegmentCacheStatus::Queued { .. } + )); + assert!(matches!( + session.segments.get(&2).expect("ready segment remains").status, + SegmentCacheStatus::Ready { .. } + )); + assert!(matches!(session.maps.get(&ProxyMapId(1)).expect("map remains").status, MapCacheStatus::Queued { .. })); + } + assert!(app_state.active_provider.is_provider_reserved_for_other_session(&account_name, Some("other-owner")).await); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn hls_access_lease_sync_keeps_binding_and_queue_when_active_count_remains_positive() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + activate_test_hls_access_lease(&app_state, &proxy_session_id, "still-active-lease", 1_000, 10_000).await; + { + let mut session = session.write().await; + session.activity.active_access_lease_count = 1; + session.segments.insert(1, test_segment_entry(&proxy_session_id, 1, SegmentCacheStatus::Discovered)); + session.queue_segment_fetch_candidate(1, SegmentFetchPriority::Prefetch, 1_100); + } + let old_generation = session.read().await.activity.origin_work_generation; + + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + 1_500, + ) + .await; + + let session = session.read().await; + assert_eq!(session.activity.active_access_lease_count, 1); + assert!(matches!( + session.origin_account_binding.as_ref().expect("binding exists").binding_mode, + HlsOriginAccountBindingMode::Active + )); + assert_eq!(session.activity.origin_work_generation, old_generation); + assert!(!session.segment_prefetch_queue.is_empty()); + assert!(matches!( + session.segments.get(&1).expect("segment remains queued").status, + SegmentCacheStatus::Queued { .. } + )); +} + +#[tokio::test] +async fn hls_access_lease_sync_zero_to_zero_is_idempotent() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let old_generation = session.read().await.activity.origin_work_generation; + + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + 3_000, + ) + .await; + + let session = session.read().await; + assert_eq!(session.activity.active_access_lease_count, 0); + assert!(matches!( + session.origin_account_binding.as_ref().expect("binding exists").binding_mode, + HlsOriginAccountBindingMode::Active + )); + assert_eq!(session.activity.origin_work_generation, old_generation); +} + +#[tokio::test] +async fn hls_access_lease_gc_prepass_releases_user_without_detaching_origin_binding() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await; + let proxy_session_id = session.read().await.proxy_session_id.clone(); + activate_test_hls_access_lease(&app_state, &proxy_session_id, "gc-expired-lease", 1_000, 1_000).await; + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + 1_000, + ) + .await; + let old_generation = session.read().await.activity.origin_work_generation; + + app_state + .hls_proxy + .sync_all_session_access_leases_and_detach_if_needed(&app_state.active_users, &app_state.active_provider, 3_000) + .await; + let _ = app_state.hls_proxy.run_garbage_collection_once(3_000).await.expect("gc should run"); + + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id) + .await + .expect("detach keeps shared hls session"); + let session = session.read().await; + assert_eq!(session.activity.active_access_lease_count, 0); + assert!(matches!( + session.origin_account_binding.as_ref().expect("binding exists").binding_mode, + HlsOriginAccountBindingMode::Active + )); + assert_eq!(session.activity.origin_work_generation, old_generation); +} + +fn test_addr() -> SocketAddr { "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!()) } + +fn test_addr_with_port(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) } + +fn test_fingerprint() -> Fingerprint { Fingerprint::new("test".to_string(), "127.0.0.1".to_string(), test_addr()) } + +fn test_fingerprint_with_addr(addr: SocketAddr) -> Fingerprint { + Fingerprint::new(format!("test-{}", addr.port()), "127.0.0.1".to_string(), addr) +} + +async fn create_active_hls_user_session(app_state: &Arc) { + create_active_hls_user_session_with( + app_state, + "hls-session-token", + "origin-provider", + "http://origin.example.com/live/12345.m3u8", + test_addr(), + ) + .await; +} + +async fn create_active_hls_user_session_with( + app_state: &Arc, + session_token: &str, + provider: &str, + stream_url: &str, + addr: SocketAddr, +) { + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.max_connections = 1; + app_state + .active_users + .create_user_session(CreateUserSessionParams { + user: &user, + session_token, + virtual_id: 12345, + provider, + stream_url, + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; +} + +async fn mark_hls_user_session_exhausted(app_state: &Arc) { + let user = app_state.app_config.get_user_credentials("hls-user").expect("configured HLS test user"); + let addr = test_addr(); + app_state + .active_users + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "hls-session-token", + virtual_id: 12345, + provider: "origin-provider", + stream_url: "http://origin.example.com/live/12345.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Exhausted, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; +} + +fn test_hls_access_context(proxy_session_id: ProxySessionId, access_lease_id: HlsAccessLeaseId) -> HlsAccessContext { + test_hls_access_context_with(proxy_session_id, access_lease_id, "hls-session-token", test_fingerprint().key) +} + +fn test_hls_access_context_with( + proxy_session_id: ProxySessionId, + access_lease_id: HlsAccessLeaseId, + user_session_token: &str, + client_fingerprint: String, +) -> HlsAccessContext { + HlsAccessContext { + username: "hls-user".to_string(), + user_session_token: user_session_token.to_string(), + proxy_session_id, + input_id: 1, + stream_ref: "12345".to_string(), + virtual_id: 12345, + known_bitrate_bps: None, + lease_id: access_lease_id, + family_key: HlsPlaybackFamilyKey::new("hls-user", client_fingerprint), + epg_reference_ts: None, + archive_origin_url: None, + } +} + +#[tokio::test] +async fn hls_cache_stream_channel_uses_archive_epg_context() { + let app_state = test_app_state(); + let mut access = test_hls_access_context( + ProxySessionId("proxy-archive".to_string()), + HlsAccessLeaseId("lease-archive".to_string()), + ); + access.epg_reference_ts = Some(1_784_898_000); + access.archive_origin_url = Some("http://provider/channel/timeshift_abs-1784898000.m3u8".to_string()); + let origin_source = + HlsOriginSource::new(1, Arc::from("test-input"), "80510", HlsOriginSourceKind::M3uMediaPlaylist) + .with_archive_reference(1_784_898_000); + + let channel = super::build_hls_cache_stream_channel( + &app_state, + &access, + &origin_source, + &ProxySessionId("proxy-archive".to_string()), + ) + .await; + + assert_eq!(channel.item_type, PlaylistItemType::Catchup); + assert_eq!(channel.cluster, XtreamCluster::Video); + assert_eq!(channel.epg_reference_ts, Some(1_784_898_000)); +} + +#[tokio::test] +async fn hls_cache_manifest_context_restores_leased_archive_origin() -> Result<(), StatusCode> { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let input = ConfigInput { + id: 1, + name: Arc::from("test-input"), + input_type: InputType::M3u, + enabled: true, + ..ConfigInput::default() + }; + let mut target = test_m3u_hls_share_target(); + target.name = "default".to_string(); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + cache_test_m3u_hls_item( + &app_state, + &target, + test_m3u_hls_item(&input, 12345, "80510", "http://provider/channel/mono.m3u8"), + ) + .await; + + let archive_url = "http://provider/channel/timeshift_abs-1784898000.m3u8"; + let mut access = test_hls_access_context( + ProxySessionId("proxy-archive".to_string()), + HlsAccessLeaseId("lease-archive".to_string()), + ); + access.stream_ref = "80510".to_string(); + access.epg_reference_ts = Some(1_784_898_000); + access.archive_origin_url = Some(archive_url.to_string()); + + let context = super::resolve_hls_playback_manifest_request_context(&app_state, &access, &HeaderMap::new()).await?; + + assert_eq!(context.hls_url, archive_url); + assert_eq!(context.origin_source.stream_ref, "80510"); + assert_eq!(context.origin_source.archive_reference, Some(1_784_898_000)); + Ok(()) +} + +async fn prepare_pending_test_hls_access_lease( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, +) { + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + access_lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "12345".to_string(), + 12345, + super::current_time_millis(), + super::hls_access_lease_ttl_ms(app_state), + )) + .await; +} + +#[tokio::test] +async fn hls_cache_manifest_cold_start_synchronously_returns_initial_manifest() { + let origin = spawn_test_segment_origin( + b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", + ) + .await; + let input_name = Arc::::from("test-input"); + let input = ConfigInput { + id: 1, + name: Arc::clone(&input_name), + input_type: InputType::Xtream, + url: origin.base_url.clone(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("hls cache should handle valid live hls entrypoint"); + + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0")); + assert!(body.contains(&format!("/hls/shared/live/{}/{}/000000.ts", proxy_session_id.0, access_lease_id.0))); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + let session = app_state + .hls_proxy + .sessions() + .get_by_key(&session_key) + .await + .expect("cold start should create shared hls session"); + { + let session = session.read().await; + assert!(session.last_rendered_manifest.is_some()); + let binding = session.origin_account_binding.as_ref().expect("plain http input still has account binding"); + assert_eq!(binding.input_name.as_ref(), "test-input"); + assert_eq!(binding.account_name.as_ref(), "test-input"); + } + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn canonical_recovery_from_provisioning_marks_normal_handoff_boundary() { + let origin = spawn_test_segment_origin( + b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("test-input"), + input_type: InputType::Xtream, + url: origin.base_url.clone(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), 12345, super::current_time_millis()); + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("canonical hls cache should recover from provisioning"); + + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); + assert!(body.contains("#EXT-X-DISCONTINUITY\n#EXTINF:4.000,")); + assert!(!app_state.hls_provisioning.has_consumer(&input.name, 12345, super::current_time_millis())); +} + +#[tokio::test] +async fn canonical_recovery_from_provisioning_marks_transient_handoff_boundary() { + let origin = spawn_test_segment_origin( + b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"key.key\"\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n#EXTINF:4.0,\n000125.ts\n", + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("test-input"), + input_type: InputType::Xtream, + url: origin.base_url.clone(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + app_state.hls_provisioning.touch_consumer(Arc::clone(&input.name), 12345, super::current_time_millis()); + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Activated, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("canonical hls cache should recover from provisioning"); + + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); + assert!(body.contains("#EXT-X-DISCONTINUITY\n#EXTINF:4.0,")); + assert!(!app_state.hls_provisioning.has_consumer(&input.name, 12345, super::current_time_millis())); +} + +#[tokio::test] +async fn hls_cache_manifest_cold_start_supports_m3u_hls_origin_source() { + let origin = spawn_test_segment_origin( + b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:77\n#EXTINF:4.0,\nseg-77.ts\n#EXTINF:4.0,\nseg-78.ts\n#EXTINF:4.0,\nseg-79.ts\n", + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("m3u-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/channel/index.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("hls cache should handle m3u hls media playlists"); + + assert_eq!(response.status(), StatusCode::OK); + let session = + app_state.hls_proxy.sessions().get_by_key(&session_key).await.expect("m3u hls should create shared session"); + let session = session.read().await; + assert_eq!(session.origin_source.source_kind, HlsOriginSourceKind::M3uMediaPlaylist); + let binding = session.origin_account_binding.as_ref().expect("m3u hls input still has account binding"); + assert_eq!(binding.input_name.as_ref(), "m3u-input"); + assert_eq!(binding.account_name.as_ref(), "m3u-input"); +} + +const AES_TEST_MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:77\n#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",KEYFORMAT=\"identity\",KEYFORMATVERSIONS=\"1\"\n#EXTINF:12,\n77.ts\n#EXTINF:12,\n78.ts\n#EXTINF:12,\n79.ts\n#EXTINF:12,\n80.ts\n#EXTINF:12,\n81.ts\n#EXTINF:12,\n82.ts\n"; +const AES_TEST_KEY_BYTES: &[u8] = b"0123456789abcdef"; +const AES_TEST_ROTATED_KEY_BYTES: &[u8] = b"fedcba9876543210"; +const AES_TEST_PLAINTEXT_SEGMENT: &[u8] = + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")); + +struct AesEndpointFixture { + _temp_dir: tempfile::TempDir, + origin: TestSegmentOrigin, + input: ConfigInput, + app_state: Arc, + request_url: String, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + access_lease_id: HlsAccessLeaseId, + key_uri: String, + base_manifest: HlsLeaseManifestSnapshot, + asset: Arc, +} + +async fn assert_aes_live_endpoint( + app_state: &Arc, + origin: &TestSegmentOrigin, + access_lease_id: &HlsAccessLeaseId, + live_body: &str, +) -> String { + let key_uri = live_body + .lines() + .find(|line| line.starts_with("#EXT-X-KEY:METHOD=AES-128")) + .and_then(|line| line.split_once("URI=\"").map(|(_, tail)| tail)) + .and_then(|tail| tail.split_once('"').map(|(uri, _)| uri.to_string())) + .expect("opaque key URI"); + assert!(key_uri.contains(&format!("/{}/r/", access_lease_id.0))); + assert!(!live_body.contains("key.bin")); + assert!(live_body.contains("#EXT-X-VERSION:5\n")); + let proxy_media_sequence = live_body + .lines() + .find_map(|line| line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:")) + .and_then(|value| value.parse::().ok()) + .expect("proxy media sequence"); + assert_ne!(proxy_media_sequence, 77); + assert!(live_body.contains("IV=0x0000000000000000000000000000004d")); + assert_eq!(origin.key_request_count(), 1); + let segment_uris = live_body + .lines() + .filter(|line| line.starts_with("/hls/shared/live/") && !line.contains("/r/")) + .collect::>(); + assert_eq!(segment_uris.len(), 6); + for (index, segment_uri) in segment_uris.into_iter().enumerate() { + let response = get_response(Arc::clone(app_state), segment_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let served = response_body(response).await; + let origin_sequence = 77_u64.saturating_add(u64::try_from(index).expect("test segment index")); + let expected = encrypt_test_aes128_cbc_pkcs7( + AES_TEST_PLAINTEXT_SEGMENT, + AES_TEST_KEY_BYTES, + test_hls_sequence_iv(origin_sequence), + ); + assert_eq!(served.as_ref(), expected.as_slice()); + assert_ne!(served.as_ref(), AES_TEST_PLAINTEXT_SEGMENT); + assert_eq!(origin.key_request_count(), 1); + } + key_uri +} + +async fn aes_endpoint_fixture() -> AesEndpointFixture { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let origin = spawn_test_encrypted_hls_origin( + AES_TEST_MANIFEST, + Arc::from(AES_TEST_KEY_BYTES), + Arc::from(AES_TEST_PLAINTEXT_SEGMENT), + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("encrypted-m3u-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_hls_proxy_and_inputs( + Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)), + vec![Arc::new(input.clone())], + ); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/channel/index.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("encrypted-normal-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("compatible AES origin should enter normal HLS cache"); + assert_eq!(response.status(), StatusCode::OK); + let live_body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + let key_uri = assert_aes_live_endpoint(&app_state, &origin, &access_lease_id, &live_body).await; + assert_eq!(origin.segment_request_count(), 6); + let session = app_state.hls_proxy.sessions().get_by_key(&session_key).await.expect("normal encrypted session"); + assert_eq!(session.read().await.mode, HlsSessionMode::NormalCacheTimeline); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("live encrypted lease snapshot"); + let base_manifest = lease.last_manifest_snapshot.expect("authoritative lease manifest"); + assert_eq!(base_manifest.delivery_mode, HlsManifestDeliveryMode::NormalCacheTimeline); + assert_eq!( + base_manifest.active_encryption.as_ref().and_then(|encryption| encryption.iv.as_deref()), + Some("0x00000000000000000000000000000052") + ); + let asset = terminal_test_asset(); + let evidence = prepare_terminal_base_evidence( + &session, + app_state.hls_proxy.segment_cache(), + &base_manifest, + super::current_time_millis(), + ) + .await; + assert_eq!(evidence.track_signature(), Some(asset.track_signature().clone())); + assert_eq!(evidence.key_bindings().len(), 1); + assert_eq!(origin.key_request_count(), 1); + evidence.release(); + AesEndpointFixture { + _temp_dir: temp_dir, + origin, + input, + app_state, + request_url, + session, + proxy_session_id, + access_lease_id, + key_uri, + base_manifest, + asset, + } +} + +async fn install_aes_terminal_plan(fixture: &AesEndpointFixture) -> TransientObjectCacheKey { + let key_response = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; + assert_eq!(key_response.status(), StatusCode::OK); + assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_KEY_BYTES); + assert_eq!(fixture.origin.key_request_count(), 1); + let evidence_key_id = { + let mut session = fixture.session.write().await; + let key_id = session + .transient + .resources + .values() + .find(|resource| resource.kind == TransientResourceKind::Key) + .map(|resource| resource.id.clone()) + .expect("normal key resource"); + session.last_rendered_manifest = None; + session.transient.last_manifest_body = None; + session.transient.resources.get_mut(&key_id).expect("key resource").expires_at_ms = 0; + for (key, object) in &mut session.transient.object_cache { + if key.transient_resource_id() == &key_id { + object.expires_at_ms = 0; + } + } + key_id + }; + let evidence = prepare_terminal_base_evidence( + &fixture.session, + fixture.app_state.hls_proxy.segment_cache(), + &fixture.base_manifest, + super::current_time_millis(), + ) + .await; + assert_eq!(evidence.track_signature(), Some(fixture.asset.track_signature().clone())); + assert_eq!(fixture.origin.key_request_count(), 1); + fixture + .app_state + .hls_proxy + .run_garbage_collection_once(super::current_time_millis()) + .await + .expect("evidence-pinned GC"); + { + let session = fixture.session.read().await; + assert!(session.transient.resources.contains_key(&evidence_key_id)); + assert!(session.transient.object_cache.keys().any(|key| key.transient_resource_id() == &evidence_key_id)); + } + let plan = build_terminal_tail_plan(HlsTerminalTailBuildInput { + generation: HlsTerminalTailGeneration(23), + created_at_ms: super::current_time_millis(), + base_availability: evidence.availability(), + base_track_signature: evidence.track_signature(), + base_splice_evidence: evidence.splice_evidence().cloned(), + terminal_splice_evidence: Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test(&fixture.asset)), + base_timing: evidence.timing().cloned(), + base_key_bindings: evidence.key_bindings(), + expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable(HlsTerminalAssetIdentity::from_asset( + &fixture.asset, + )), + base_manifest: fixture.base_manifest.clone(), + anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test( + &fixture.asset, + fixture.base_manifest.target_duration_ms, + ), + asset: Arc::clone(&fixture.asset), + }) + .expect("READY AES key permits safe terminal reset"); + let protection = HlsTerminalTailProtection { + generation: plan.generation, + base_proxy_seqs: Arc::clone(&plan.protected_base_proxy_seqs), + key_bindings: plan.key_bindings(), + }; + assert_eq!(protection.key_bindings.len(), 1); + assert_eq!(protection.key_bindings[0].resource_id(), &evidence_key_id); + let frozen_source_cache_key = protection.key_bindings[0].source_cache_key().clone(); + { + let mut leases = fixture.app_state.hls_proxy.access_leases().write().await; + let mut lease = leases.remove_access_lease(&fixture.access_lease_id).expect("live lease"); + lease.playback_mode = HlsLeasePlaybackMode::TerminalTail(Arc::new(plan)); + leases.prepare_access_lease(lease); + } + fixture.session.write().await.install_terminal_tail_protection(fixture.access_lease_id.clone(), protection); + evidence.release(); + frozen_source_cache_key +} + +async fn assert_aes_terminal_endpoints(fixture: &AesEndpointFixture) { + let manifest_uri = + format!("/hls/shared/live/{}/{}/manifest.m3u8", fixture.proxy_session_id.0, fixture.access_lease_id.0); + let response = get_response(Arc::clone(&fixture.app_state), &manifest_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal utf8"); + let key = body.find("#EXT-X-KEY:METHOD=AES-128").expect("base AES key"); + let reset = body.find("#EXT-X-KEY:METHOD=NONE").expect("clear key reset"); + let discontinuity = body[reset..].find("#EXT-X-DISCONTINUITY").expect("terminal discontinuity") + reset; + assert!(key < reset && reset < discontinuity); + assert!(body.contains("IV=0x00000000000000000000000000000052")); + assert!(body.contains("#EXT-X-VERSION:5\n")); + assert!(body.ends_with("#EXT-X-ENDLIST\n")); + let key_response = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; + assert_eq!(key_response.status(), StatusCode::OK); + assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_KEY_BYTES); + assert_eq!(fixture.origin.key_request_count(), 1); + let range = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, Some("bytes=4-7")).await; + assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response_body(range).await.as_ref(), &AES_TEST_KEY_BYTES[4..=7]); + let unsatisfiable = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, Some("bytes=16-")).await; + assert_eq!(unsatisfiable.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(unsatisfiable.headers()[header::CONTENT_RANGE], "bytes */16"); +} + +async fn assert_aes_rotated_live_key( + fixture: &AesEndpointFixture, + frozen_source_cache_key: &TransientObjectCacheKey, +) -> String { + let manifest_requests_before = fixture.origin.manifest_request_count(); + let segment_requests_before = fixture.origin.segment_request_count(); + fixture.origin.set_key_bytes(Arc::from(AES_TEST_ROTATED_KEY_BYTES)).await; + let live_lease_id = HlsAccessLeaseId("encrypted-rotated-live-lease".to_string()); + let access_context = test_hls_access_context(fixture.proxy_session_id.clone(), live_lease_id.clone()); + prepare_pending_test_hls_access_lease(&fixture.app_state, &fixture.proxy_session_id, &live_lease_id).await; + let response = super::try_hls_cache_canonical_manifest_response( + &fixture.app_state, + &test_fingerprint(), + &access_context, + &fixture.proxy_session_id, + &live_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &fixture.request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&fixture.request_url), + input: &fixture.input, + origin_source: super::build_hls_origin_source(&fixture.input, "12345"), + }, + HeaderMap::new(), + None, + "/m3u-stream/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("a new live lease should reuse the recovered shared session"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(fixture.origin.manifest_request_count(), manifest_requests_before.saturating_add(1)); + assert_eq!(fixture.origin.segment_request_count(), segment_requests_before); + let live_lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&live_lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await + .expect("rotated live lease snapshot"); + assert_eq!(live_lease.playback_mode, HlsLeasePlaybackMode::Live); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("rotated manifest utf8"); + let rotated_key_uri = body + .lines() + .find(|line| line.starts_with("#EXT-X-KEY:METHOD=AES-128")) + .and_then(|line| line.split_once("URI=\"").map(|(_, tail)| tail)) + .and_then(|tail| tail.split_once('"').map(|(uri, _)| uri.to_string())) + .expect("rotated live key URI"); + let key_response = get_response(Arc::clone(&fixture.app_state), &rotated_key_uri, None).await; + assert_eq!(key_response.status(), StatusCode::OK); + assert_eq!(response_body(key_response).await.as_ref(), AES_TEST_ROTATED_KEY_BYTES); + assert_eq!(fixture.origin.key_request_count(), 2); + assert!(fixture + .app_state + .hls_proxy + .segment_cache() + .metadata(frozen_source_cache_key) + .await + .expect("A metadata lookup") + .is_none()); + let terminal_key = get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await; + assert_eq!(terminal_key.status(), StatusCode::OK); + assert_eq!(response_body(terminal_key).await.as_ref(), AES_TEST_KEY_BYTES); + assert_eq!(fixture.origin.key_request_count(), 2); + rotated_key_uri +} + +async fn expire_aes_terminal_lease(fixture: &AesEndpointFixture, rotated_key_uri: &str) { + let expired_at_ms = super::current_time_millis().saturating_sub(1); + { + let mut leases = fixture.app_state.hls_proxy.access_leases().write().await; + let mut lease = leases.remove_access_lease(&fixture.access_lease_id).expect("terminal lease"); + lease.valid_until_ms = expired_at_ms; + leases.prepare_access_lease(lease); + } + fixture + .app_state + .hls_proxy + .handle_lifecycle_event( + &fixture.app_state.active_users, + &fixture.app_state.active_provider, + HlsLifecycleEvent { + key: HlsLifecycleEventKey::AccessLeaseValidity { + lease_id: fixture.access_lease_id.clone(), + proxy_session_id: fixture.proxy_session_id.clone(), + }, + due_at_ms: expired_at_ms, + }, + super::current_time_millis(), + ) + .await; + fixture.app_state.hls_proxy.run_garbage_collection_once(super::current_time_millis()).await.expect("released GC"); + assert!(fixture.session.read().await.terminal_tail_protection(&fixture.access_lease_id).is_none()); + assert_eq!( + get_response(Arc::clone(&fixture.app_state), &fixture.key_uri, None).await.status(), + StatusCode::NOT_FOUND + ); + let live_key = get_response(Arc::clone(&fixture.app_state), rotated_key_uri, None).await; + assert_eq!(live_key.status(), StatusCode::OK); + assert_eq!(response_body(live_key).await.as_ref(), AES_TEST_ROTATED_KEY_BYTES); +} + +#[tokio::test] +async fn aes_128_normal_origin_key_and_terminal_lifecycle_are_endpoint_safe() { + let fixture = aes_endpoint_fixture().await; + let frozen_source_cache_key = install_aes_terminal_plan(&fixture).await; + assert_aes_terminal_endpoints(&fixture).await; + let rotated_key_uri = assert_aes_rotated_live_key(&fixture, &frozen_source_cache_key).await; + expire_aes_terminal_lease(&fixture, &rotated_key_uri).await; +} + +#[tokio::test] +async fn hls_cache_manifest_unpublished_lease_uses_same_finite_fallback_for_created_and_reused_session() { + let mut rendered_bodies = Vec::new(); + for expected_outcome in [HlsSessionStoreOutcome::Created, HlsSessionStoreOutcome::Reused] { + let input_name = Arc::::from("test-input"); + let origin = spawn_test_status_origin(StatusCode::NOT_FOUND, b"missing").await; + let input = ConfigInput { + id: 1, + name: Arc::clone(&input_name), + input_type: InputType::Xtream, + url: origin.base_url.clone(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + enable_channel_unavailable_custom_response(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + if expected_outcome == HlsSessionStoreOutcome::Reused { + let (session, outcome) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key, + origin_source.clone(), + &app_state.get_encrypt_secret(), + super::current_time_millis(), + ) + .await; + assert_eq!(outcome, HlsSessionStoreOutcome::Created); + session.write().await.origin_control.path_condition = HlsOriginPathCondition::AcceptanceConflict; + } + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: request_url.as_str(), + session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url.as_str()), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("hls cache should handle valid live hls entrypoint"); + + assert_eq!(response.status(), StatusCode::OK, "session outcome: {expected_outcome:?}"); + assert!(response.headers().get(header::RETRY_AFTER).is_none(), "custom response must not expose retry-after"); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + assert!(body.contains("#EXT-X-ENDLIST")); + assert!(!body.contains("/hls/shared/live/"), "standalone response must not expose an unready normal URI"); + rendered_bodies.push(body); + + let snapshot = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("lease remains available for strict cold-start handling"); + assert_eq!(snapshot.playback_mode, HlsLeasePlaybackMode::Live); + assert!(snapshot.last_manifest_snapshot.is_none()); + } + assert_eq!(rendered_bodies[0], rendered_bodies[1]); +} + +#[tokio::test] +async fn hls_cache_manifest_cold_start_client_abort_does_not_leave_refresh_in_flight() { + let origin = spawn_test_transient_origin_with_delayed_response( + "200 OK", + &[("Content-Type", "application/vnd.apple.mpegurl")], + "#EXTM3U\n#EXT-X-VERSION:3\n", + Duration::from_millis(100), + ) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("test-input"), + input_type: InputType::Xtream, + url: origin.base_url.clone(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let hls_dto = + HlsCacheConfigDto { origin_manifest_timeout_ms: shared::model::Millis::new(1_000), ..Default::default() }; + let hls_config = HlsCacheConfig::from(&hls_dto); + let app_state = test_app_state_with_hls_proxy_and_inputs( + Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config)), + vec![Arc::new(input.clone())], + ); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let request_url = format!("{}/live/user/pass/12345.m3u8", origin.base_url); + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + + let app_state_for_request = Arc::clone(&app_state); + let request_handle = tokio::spawn(async move { + super::try_hls_cache_canonical_manifest_response( + &app_state_for_request, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: &request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(&request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + }); + + let session = wait_for_hls_test_session(&app_state, &session_key).await; + wait_for_hls_refresh_in_flight(&session).await; + request_handle.abort(); + let _ = request_handle.await; + + for _ in 0..200 { + if !session.read().await.origin_refresh.in_flight { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let session = session.read().await; + assert!(!session.origin_refresh.in_flight); + assert!(session.origin_refresh.last_fetch_finished_at_ms.is_some()); +} + +#[tokio::test] +async fn hls_cache_canonical_prepare_service_unavailable_sets_retry_after() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let input = ConfigInput { + id: 1, + name: Arc::from("alias-input"), + aliases: Some(vec![crate::model::ConfigInputAlias { + id: 2, + name: Arc::from("alias-account"), + url: "http://alias.example.com".to_string(), + username: Some("alias-user".to_string()), + password: Some("alias-pass".to_string()), + priority: 0, + max_connections: 1, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..ConfigInput::default() + }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("canonical hls cache response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(header::RETRY_AFTER).expect("retry after"), "2"); +} + +#[tokio::test] +async fn hls_origin_account_rebind_failure_sets_backoff_without_changing_session_identity() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let input = ConfigInput { id: 1, name: Arc::from("stale-input"), ..ConfigInput::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + let (session, _) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key.clone(), + origin_source.clone(), + &app_state.get_encrypt_secret(), + 1_000, + ) + .await; + { + let mut session_guard = session.write().await; + session_guard.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::clone(&input.name), + Arc::from("removed-account"), + &proxy_session_id, + 1_000, + )); + } + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: request_url, + session_entry_url: super::HlsOriginEntryUrl::direct_http(request_url), + input: &input, + origin_source, + }, + HeaderMap::new(), + None, + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("canonical hls cache response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(header::RETRY_AFTER).expect("retry after"), "2"); + let session_guard = session.read().await; + assert_eq!(session_guard.key, session_key); + assert_eq!(session_guard.proxy_session_id, proxy_session_id); + let binding = session_guard.origin_account_binding.as_ref().expect("stale binding remains"); + assert_eq!(binding.account_name.as_ref(), "removed-account"); + assert_eq!(binding.generation, 0); + assert_eq!(session_guard.origin_account_rebind.consecutive_rebind_failures, 1); + assert!(session_guard.origin_account_rebind.next_rebind_allowed_at_ms.is_some()); +} + +#[tokio::test] +async fn hls_cache_entry_returns_master_playlist_without_origin_refresh_or_session_creation() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + + let response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + Some("/iptv"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok()), + Some("application/vnd.apple.mpegurl") + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).and_then(|value| value.to_str().ok()), + Some("private, no-store, no-cache, must-revalidate") + ); + assert!(response.headers().get(header::LOCATION).is_none()); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(response.headers().get(header::VARY).is_none()); + let content_length = response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .expect("content length"); + let body = response_body(response).await; + assert_eq!(content_length, body.len()); + let body = std::str::from_utf8(&body).expect("master playlist should be UTF-8"); + assert_eq!(body.matches("/iptv").count(), 1); + let variant_uri = body.lines().nth(2).expect("single variant URI"); + assert!(variant_uri.starts_with("/iptv/hls/shared/live/")); + assert!(variant_uri.ends_with("/manifest.m3u8")); + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(variant_uri).to_string()); + assert_eq!(access_lease_id.0.len(), 22); + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(variant_uri).to_string()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("entry access lease"); + assert!(app_state + .active_users + .get_and_update_user_session(&lease.username, &lease.user_session_token) + .await + .is_some()); + assert!(app_state.hls_proxy.sessions().get_by_key(&session_key).await.is_none()); + assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[test] +fn hls_master_playlist_response_diagnostic_reports_complete_response_contract_for_every_bandwidth_source() { + for selection in [ + super::HlsMasterBandwidthSelection::resolve(Some(2_500_000), Some(2_000_000)), + super::HlsMasterBandwidthSelection::resolve(None, Some(2_000_000)), + super::HlsMasterBandwidthSelection::resolve(None, None), + ] { + let rendered = super::hls_entry_master_playlist_response( + &ProxySessionId("diagnostic-proxy-session".to_string()), + &HlsAccessLeaseId("diagnostic-lease".to_string()), + selection.bandwidth(), + Some("/iptv"), + ); + let fields = super::HlsMasterPlaylistResponseDiagnostic { + lease: "lease-safe".to_string(), + session: "session-safe".to_string(), + proxy_session: "proxy-safe".to_string(), + user_session: "user-safe".to_string(), + virtual_id: 12345, + bandwidth_bps: selection.bandwidth().advertised_bps(), + bandwidth_source: selection.source().as_log_value(), + content_length: rendered.content_length, + } + .to_string(); + + assert!(fields.contains("lease=lease-safe session=session-safe proxy_session=proxy-safe")); + assert!(fields.contains("user_session=user-safe virtual_id=12345")); + assert!(fields.contains(&format!( + "bandwidth_bps={} bandwidth_source={}", + selection.bandwidth().advertised_bps(), + selection.source().as_log_value() + ))); + assert!(fields.contains("status=200")); + assert!(fields.ends_with(&format!("content_length={}", rendered.content_length))); + assert_eq!( + rendered + .response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()), + Some(rendered.content_length) + ); + } +} + +struct SharedRequestFlowFixture { + _origin: TestSegmentOrigin, + app_state: Arc, + input: ConfigInput, + target: ConfigTarget, + user: ProxyUserCredentials, + origin_manifest_url: String, + entry_path: String, +} + +async fn shared_request_flow_fixture() -> SharedRequestFlowFixture { + const MANIFEST: &[u8] = b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:4\n\ + #EXT-X-MEDIA-SEQUENCE:123\n#EXTINF:4.0,\n000123.ts\n#EXTINF:4.0,\n000124.ts\n\ + #EXTINF:4.0,\n000125.ts\n#EXTINF:4.0,\n000126.ts\n#EXTINF:4.0,\n000127.ts\n\ + #EXTINF:4.0,\n000128.ts\n"; + let origin = spawn_test_segment_origin(MANIFEST).await; + let input = ConfigInput { + id: 1, + name: Arc::from("request-flow-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let mut target = test_m3u_hls_share_target(); + target.name = "default".to_string(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + configure_default_test_server(&app_state); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + let origin_manifest_url = format!("{}/channel/index.m3u8", origin.base_url); + cache_test_m3u_hls_item(&app_state, &target, test_m3u_hls_item(&input, 12345, "channel-a", &origin_manifest_url)) + .await; + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); + SharedRequestFlowFixture { _origin: origin, app_state, input, target, user, origin_manifest_url, entry_path } +} + +async fn shared_request_flow_entry(fixture: &SharedRequestFlowFixture, fingerprint: &Fingerprint) -> Response { + super::handle_hls_stream_request( + fingerprint, + &fixture.app_state, + &fixture.user, + &fixture.target, + None, + None, + &fixture.origin_manifest_url, + None, + test_hls_entry_stream_context(12345, "channel-a", Some(2_500_000)), + &fixture.input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &fixture.entry_path, + ) + .await + .into_response() +} + +#[tokio::test] +async fn hls_cache_archive_entry_uses_distinct_identity_and_preserves_origin() -> Result<(), &'static str> { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + configure_default_test_server(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let input = test_hls_input(); + let target = test_hls_share_target(true); + let archive_url = "http://origin.example.com/live/user/pass/timeshift_abs-1784898000.m3u8"; + + let response = super::handle_hls_stream_request( + &test_fingerprint(), + &app_state, + &user, + &target, + None, + None, + archive_url, + Some(1_784_898_000), + test_hls_entry_stream_context(12345, "80510", None), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &super::build_virtual_hls_entry_path(&target, &input, &user, 12345), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + let (_, media_playlist_uri) = single_variant_master_playlist(response).await; + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); + let live_source = super::build_hls_origin_source(&input, "80510"); + let archive_source = + super::build_hls_origin_source_for_playback(&input, "80510", Some(1_784_898_000), Some(archive_url)); + assert_ne!(proxy_session_id, build_proxy_session_id(&live_source.session_key(), &app_state.get_encrypt_secret())); + assert_eq!( + proxy_session_id, + build_proxy_session_id(&archive_source.session_key(), &app_state.get_encrypt_secret()) + ); + + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); + let lease = app_state + .hls_proxy + .access_leases() + .write() + .await + .response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .ok_or("archive access lease")?; + assert_eq!(lease.stream_ref, "80510"); + assert_eq!(lease.epg_reference_ts, Some(1_784_898_000)); + assert_eq!(lease.archive_origin_url.as_deref(), Some(archive_url)); + assert!(super::is_m3u_catchup_session_token(&lease.user_session_token)); + Ok(()) +} + +#[tokio::test] +async fn shared_hls_request_flow_keeps_media_playlist_lease_bound_across_reloads() { + let fixture = shared_request_flow_fixture().await; + let app_state = &fixture.app_state; + let entry_response = shared_request_flow_entry(&fixture, &test_fingerprint()).await; + + assert_eq!(entry_response.status(), StatusCode::OK); + assert!(!entry_response.headers().contains_key(header::LOCATION)); + let (_, media_playlist_uri) = single_variant_master_playlist(entry_response).await; + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); + + let first_media_response = get_response(Arc::clone(app_state), &media_playlist_uri, None).await; + assert_eq!(first_media_response.status(), StatusCode::OK); + assert!(!first_media_response.headers().contains_key(header::LOCATION)); + let first_media_body = + String::from_utf8(response_body(first_media_response).await.to_vec()).expect("media playlist utf8"); + let segment_uri = first_media_body + .lines() + .find(|line| line.starts_with("/hls/shared/live/") && path_has_extension(line, "ts")) + .expect("lease-bound segment URI") + .to_string(); + let lease_path = format!("/{}/{}/", proxy_session_id.0, access_lease_id.0); + assert!(segment_uri.contains(&lease_path)); + + let reloaded_media_response = get_response(Arc::clone(app_state), &media_playlist_uri, None).await; + assert_eq!(reloaded_media_response.status(), StatusCode::OK); + assert!(!reloaded_media_response.headers().contains_key(header::LOCATION)); + let reloaded_media_body = + String::from_utf8(response_body(reloaded_media_response).await.to_vec()).expect("reloaded media playlist utf8"); + assert_eq!(manifest_media_sequence(&reloaded_media_body), manifest_media_sequence(&first_media_body)); + assert!(reloaded_media_body.lines().any(|line| line == segment_uri)); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); + + let segment_response = get_response(Arc::clone(app_state), &segment_uri, None).await; + assert_eq!(segment_response.status(), StatusCode::OK); + assert!(!response_body(segment_response).await.is_empty()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("request-flow access lease"); + assert_eq!(lease.state, HlsAccessLeaseState::Activated); + assert!(lease.last_manifest_snapshot.is_some()); + + let second_entry_response = + shared_request_flow_entry(&fixture, &test_fingerprint_with_addr(test_addr_with_port(55131))).await; + assert_eq!(second_entry_response.status(), StatusCode::OK); + let (_, second_media_playlist_uri) = single_variant_master_playlist(second_entry_response).await; + let second_proxy_session_id = + ProxySessionId(proxy_session_id_from_variant_uri(&second_media_playlist_uri).to_string()); + let second_access_lease_id = + HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_media_playlist_uri).to_string()); + assert_eq!(second_proxy_session_id, proxy_session_id); + assert_ne!(second_access_lease_id, access_lease_id); + let second_pending_lease = app_state + .hls_proxy + .access_lease_response_snapshot(&second_access_lease_id, &second_proxy_session_id, super::current_time_millis()) + .await + .expect("second pending request-flow lease"); + assert_eq!(second_pending_lease.state, HlsAccessLeaseState::Pending); + assert!(second_pending_lease.last_manifest_snapshot.is_none()); + assert_ne!(second_pending_lease.user_session_token, lease.user_session_token); + + let second_media_response = get_response(Arc::clone(app_state), &second_media_playlist_uri, None).await; + assert_eq!(second_media_response.status(), StatusCode::OK); + assert!(!second_media_response.headers().contains_key(header::LOCATION)); + let second_media_body = + String::from_utf8(response_body(second_media_response).await.to_vec()).expect("second media playlist utf8"); + assert_eq!(manifest_media_sequence(&second_media_body), manifest_media_sequence(&first_media_body)); + let second_published_lease = app_state + .hls_proxy + .access_lease_response_snapshot(&second_access_lease_id, &second_proxy_session_id, super::current_time_millis()) + .await + .expect("second published request-flow lease"); + assert!(second_published_lease.last_manifest_snapshot.is_some()); + assert_eq!(second_published_lease.playback_mode, HlsLeasePlaybackMode::Live); + assert_eq!(app_state.hls_proxy.sessions().len().await, 1); +} + +struct PublicationLateFixture { + origin: TestSegmentOrigin, + origin_phase: Arc, + app_state: Arc, + session: HlsSessionHandle, + proxy_session_id: ProxySessionId, + access_lease_id: HlsAccessLeaseId, + media_playlist_uri: String, +} + +async fn publication_late_fixture() -> PublicationLateFixture { + let initial_manifest = Arc::<[u8]>::from(regression_origin_manifest(123, 6)); + let progressed_manifest = Arc::<[u8]>::from(regression_origin_manifest(124, 6)); + let segment = Arc::<[u8]>::from( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")) + .as_slice(), + ); + let origin_phase = Arc::new(AtomicUsize::new(0)); + let origin_phase_for_handler = Arc::clone(&origin_phase); + let origin = spawn_test_binary_origin(Arc::new(move |path| { + if path_has_extension(path, "m3u8") { + return match origin_phase_for_handler.load(Ordering::SeqCst) { + 0 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&initial_manifest)), + 1 => TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&progressed_manifest)), + _ => TestBinaryOriginResponse::new( + StatusCode::PROXY_AUTHENTICATION_REQUIRED, + Arc::<[u8]>::from(&b"retry"[..]), + ), + }; + } + TestBinaryOriginResponse::new(StatusCode::OK, Arc::clone(&segment)) + })) + .await; + let input = ConfigInput { + id: 1, + name: Arc::from("publication-late-request-input"), + input_type: InputType::M3u, + url: origin.base_url.clone(), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let mut target = test_m3u_hls_share_target(); + target.name = "default".to_string(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + enable_channel_unavailable_custom_response(&app_state); + configure_default_test_server(&app_state); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + let origin_manifest_url = format!("{}/channel/index.m3u8", origin.base_url); + cache_test_m3u_hls_item(&app_state, &target, test_m3u_hls_item(&input, 12345, "channel-a", &origin_manifest_url)) + .await; + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); + let entry_response = super::handle_hls_stream_request( + &test_fingerprint(), + &app_state, + &user, + &target, + None, + None, + &origin_manifest_url, + None, + test_hls_entry_stream_context(12345, "channel-a", None), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &entry_path, + ) + .await + .into_response(); + let (_, media_playlist_uri) = single_variant_master_playlist(entry_response).await; + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&media_playlist_uri).to_string()); + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&media_playlist_uri).to_string()); + let initial_media_response = get_response(Arc::clone(&app_state), &media_playlist_uri, None).await; + assert_eq!(initial_media_response.status(), StatusCode::OK); + let initial_media_body = + String::from_utf8(response_body(initial_media_response).await.to_vec()).expect("initial media utf8"); + let last_segment_uri = initial_media_body + .lines() + .rfind(|line| line.starts_with("/hls/shared/live/") && path_has_extension(line, "ts")) + .expect("initial manifest segment"); + assert_eq!(get_status(Arc::clone(&app_state), last_segment_uri).await, StatusCode::OK); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id) + .await + .expect("shared publication-late session"); + PublicationLateFixture { + origin, + origin_phase, + app_state, + session, + proxy_session_id, + access_lease_id, + media_playlist_uri, + } +} + +async fn refresh_publication_late_fixture(fixture: &PublicationLateFixture) -> HlsAccessLease { + let progress_generation_before = fixture.session.read().await.origin_control.progress_generation; + { + let mut session = fixture.session.write().await; + session.origin_control.last_media_progress_at_ms = Some(0); + session.origin_refresh.next_fetch_allowed_at_ms = 0; + } + let requests_before = fixture.origin.manifest_request_count(); + fixture.origin_phase.store(1, Ordering::SeqCst); + let response = get_response(Arc::clone(&fixture.app_state), &fixture.media_playlist_uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("refreshed media utf8"); + assert!(!body.contains("#EXT-X-ENDLIST")); + assert!(!body.contains("/terminal/")); + assert!(fixture.origin.manifest_request_count() > requests_before); + { + let session = fixture.session.read().await; + assert!(session.origin_control.progress_generation > progress_generation_before); + assert_eq!(session.origin_seq_highwater, Some(129)); + } + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot( + &fixture.access_lease_id, + &fixture.proxy_session_id, + super::current_time_millis(), + ) + .await + .expect("publication-late lease remains stored"); + assert_eq!(lease.state, HlsAccessLeaseState::Activated); + assert_eq!(lease.playback_mode, HlsLeasePlaybackMode::Live); + assert_eq!(fixture.app_state.hls_proxy.terminal_pending().owner_count(), 0); + lease +} + +async fn prepare_publication_late_terminal_pressure( + fixture: &PublicationLateFixture, + base_manifest: &HlsLeaseManifestSnapshot, +) { + let terminal_response = fixture.app_state.app_config.custom_stream_response.load_full(); + let terminal_asset = snapshot_terminal_media_asset( + terminal_response + .as_ref() + .and_then(|response| response.channel_unavailable.as_ref()) + .expect("configured terminal asset"), + ) + .expect("compatible terminal asset"); + let bundle_key = prepared_terminal_bundle_key( + &terminal_asset, + base_manifest.target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + let state = fixture.app_state.hls_proxy.start_prepared_terminal_bundle( + terminal_asset, + base_manifest.target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + let state = match state { + HlsPreparedTerminalBundleState::Preparing { .. } => fixture + .app_state + .hls_proxy + .wait_for_prepared_terminal_bundle(bundle_key) + .await + .expect("terminal bundle completion"), + state => state, + }; + assert!(matches!(state, HlsPreparedTerminalBundleState::Ready { .. })); + let mut session = fixture.session.write().await; + for segment in session.segments.values_mut().filter(|segment| segment.proxy_seq > base_manifest.last_proxy_seq) { + segment.duration_ms = 1; + } + session.origin_control.last_media_progress_at_ms = Some(0); + session.origin_refresh.next_fetch_allowed_at_ms = 0; +} + +async fn assert_publication_late_terminal_result(fixture: &PublicationLateFixture) { + let requests_before = fixture.origin.manifest_request_count(); + fixture.origin_phase.store(2, Ordering::SeqCst); + let response = get_response(Arc::clone(&fixture.app_state), &fixture.media_playlist_uri, None).await; + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot( + &fixture.access_lease_id, + &fixture.proxy_session_id, + super::current_time_millis(), + ) + .await + .expect("terminal lease remains stored"); + let recovery_plan = fixture.app_state.hls_proxy.manifest_recovery_burst().level.plan(); + assert!( + fixture.origin.manifest_request_count().saturating_sub(requests_before) >= recovery_plan.total_candidates() + ); + match lease.playback_mode { + HlsLeasePlaybackMode::TerminalTail(_) => { + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("terminal manifest utf8"); + assert!(body.contains("#EXT-X-ENDLIST")); + assert!(body.contains("/terminal/")); + } + HlsLeasePlaybackMode::TerminalUnavailable { .. } => { + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + HlsLeasePlaybackMode::Live => { + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("live manifest utf8"); + assert!(body.starts_with("#EXTM3U")); + assert!(!body.contains("#EXT-X-ENDLIST")); + } + HlsLeasePlaybackMode::Ended => { + panic!("active hard-failed lease must not become ended without terminal publication") + } + } +} + +#[tokio::test] +async fn publication_late_live_manifest_request_refreshes_before_terminal_evaluation() { + let fixture = publication_late_fixture().await; + let lease = refresh_publication_late_fixture(&fixture).await; + let base_manifest = lease.last_manifest_snapshot.as_ref().expect("live manifest snapshot"); + prepare_publication_late_terminal_pressure(&fixture, base_manifest).await; + assert_publication_late_terminal_result(&fixture).await; +} + +#[test] +fn hls_entry_stream_context_keeps_only_positive_live_bitrate() { + let input = ConfigInput { name: Arc::from("m3u-input"), ..ConfigInput::default() }; + let mut item = test_m3u_hls_item(&input, 1001, "channel-a", "http://origin.example.com/live.m3u8"); + item.additional_properties = Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + bitrate: 2_500_000, + ..shared::model::LiveStreamProperties::default() + }))); + + let context = super::HlsEntryStreamContext::from_playlist_item(&item).expect("entry stream context"); + assert_eq!(context.virtual_id(), 1001); + assert_eq!(context.stream_ref(), "channel-a"); + assert_eq!(context.known_bitrate_bps(), Some(2_500_000)); + + if let Some(StreamProperties::Live(properties)) = item.additional_properties.as_mut() { + properties.bitrate = 0; + } + assert_eq!( + super::HlsEntryStreamContext::from_playlist_item(&item) + .expect("entry stream context without bitrate") + .known_bitrate_bps(), + None + ); +} + +#[tokio::test] +async fn hls_cache_entry_prefers_item_bitrate_then_loads_db_without_target_rebuild() { + let temp = tempfile::tempdir().expect("temp dir"); + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut config = app_state.app_config.config.load().as_ref().clone(); + config.storage_dir = temp.path().to_string_lossy().into_owned(); + app_state.app_config.config.store(Arc::new(config)); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = + ConfigInput { id: 9, name: Arc::from("m3u-input"), input_type: InputType::M3u, ..ConfigInput::default() }; + let mut stored_item = test_m3u_hls_item(&input, 70001, "channel-a", "http://origin.example.com/live.m3u8"); + stored_item.additional_properties = Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + bitrate: 2_500_000, + ..shared::model::LiveStreamProperties::default() + }))); + let input_storage = crate::repository::build_input_storage_path(&input.name, &temp.path().to_string_lossy()); + std::fs::create_dir_all(&input_storage).expect("input storage"); + let db_path = crate::repository::get_input_m3u_playlist_file_path(&input_storage, &input.name); + let mut tree = crate::repository::BPlusTree::new(); + tree.insert(Arc::::from("channel-a"), stored_item); + tree.store(&db_path).expect("input M3U metadata"); + let request_url = "http://origin.example.com/live.m3u8"; + + let db_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + super::build_hls_origin_source(&input, "channel-a"), + 70001, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let (db_bandwidth, db_variant_uri) = single_variant_master_playlist(db_response).await; + assert_eq!(db_bandwidth, 3_000_000); + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&db_variant_uri).to_string()); + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&db_variant_uri).to_string()); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("DB-backed access lease"); + assert_eq!(lease.known_bitrate_bps, Some(2_500_000)); + + std::fs::write(&db_path, b"invalid metadata tree").expect("corrupt test metadata"); + let item_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint_with_addr(test_addr_with_port(55127)), + &user, + super::build_hls_origin_source(&input, "channel-a"), + 70001, + None, + Some(3_000_000), + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let (item_bandwidth, _) = single_variant_master_playlist(item_response).await; + assert_eq!(item_bandwidth, 3_600_000); + + let fallback_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint_with_addr(test_addr_with_port(55128)), + &user, + super::build_hls_origin_source(&input, "channel-a"), + 70001, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let (fallback_bandwidth, _) = single_variant_master_playlist(fallback_response).await; + assert_eq!(fallback_bandwidth, 1_000_000); +} + +#[tokio::test] +async fn hls_cache_entry_denies_access_lease_for_grace_without_slot_and_exhausted() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + + for (connection_permission, connection_kind) in [ + (UserConnectionPermission::GracePeriod, None), + (UserConnectionPermission::Exhausted, Some(ConnectionKind::Normal)), + ] { + let response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + connection_permission, + connection_kind, + Some("/iptv"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let variant_uri = single_variant_uri(response).await; + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&variant_uri).to_string()); + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); + let now_ms = super::current_time_millis(); + let snapshot = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms) + .await + .expect("denied lease should stay available for response rendering"); + assert_eq!(snapshot.state, HlsAccessLeaseState::Denied); + assert!(app_state + .hls_proxy + .access_lease_session_snapshot(&proxy_session_id, now_ms) + .await + .effective_origin_policy + .is_none()); + + let err = super::validate_hls_proxy_access_context( + &app_state, + &test_fingerprint(), + &proxy_session_id, + &access_lease_id.0, + now_ms, + HlsAccessAdmissionMode::ManifestPrepare, + ) + .await + .expect_err("denied lease must surface as admission denied"); + assert!(matches!(err, HlsAccessLeaseValidationError::AdmissionDenied { runtime_tail: None, .. })); + } +} + +#[tokio::test] +async fn hls_cache_entry_master_playlist_uses_cache_when_target_hls_share_enabled() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + configure_default_test_server(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let input = test_hls_input(); + let target = test_hls_share_target(true); + let original_hls_entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 1001); + + let response = super::handle_hls_stream_request( + &test_fingerprint(), + &app_state, + &user, + &target, + None, + None, + "http://origin.example.com/live/user/pass/1001.m3u8", + None, + test_hls_entry_stream_context(1001, "80510", Some(2_500_000)), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &original_hls_entry_path, + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + let (bandwidth, variant_uri) = single_variant_master_playlist(response).await; + assert_eq!(bandwidth, 3_000_000); + assert!(variant_uri.starts_with("/hls/shared/live/")); + assert!(variant_uri.ends_with("/manifest.m3u8")); + assert_eq!(app_state.hls_proxy.access_leases().read().await.len(), 1); + + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&variant_uri).to_string()); + let origin_key = HlsSessionKey::new(input.id, "80510"); + let virtual_id_key = HlsSessionKey::new(input.id, "1001"); + assert_eq!(origin_key.stable_value(), "input:1|hls|80510"); + assert_eq!(proxy_session_id, build_proxy_session_id(&origin_key, &app_state.get_encrypt_secret())); + assert_ne!(proxy_session_id, build_proxy_session_id(&virtual_id_key, &app_state.get_encrypt_secret())); + + let access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); + let lease = app_state + .hls_proxy + .access_leases() + .write() + .await + .response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .expect("access lease"); + assert_eq!(lease.stream_ref, "80510"); + assert_eq!(lease.virtual_id, 1001); + assert_eq!(lease.known_bitrate_bps, Some(2_500_000)); +} + +#[tokio::test] +async fn hls_cache_entry_shares_content_session_across_targets_but_keeps_distinct_virtual_leases() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + configure_default_test_server(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let input = test_hls_input(); + let first_target = test_hls_share_target(true); + let mut second_target = test_hls_share_target(true); + second_target.id = 2; + + let first_response = super::handle_hls_stream_request( + &test_fingerprint(), + &app_state, + &user, + &first_target, + None, + None, + "http://origin.example.com/live/user/pass/1001.m3u8", + None, + test_hls_entry_stream_context(1001, "80510", None), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &super::build_virtual_hls_entry_path(&first_target, &input, &user, 1001), + ) + .await + .into_response(); + let second_response = super::handle_hls_stream_request( + &test_fingerprint_with_addr(test_addr_with_port(55124)), + &app_state, + &user, + &second_target, + None, + None, + "http://origin.example.com/live/user/pass/9007.m3u8", + None, + test_hls_entry_stream_context(9007, "80510", None), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &super::build_virtual_hls_entry_path(&second_target, &input, &user, 9007), + ) + .await + .into_response(); + + assert_eq!(first_response.status(), StatusCode::OK); + assert_eq!(second_response.status(), StatusCode::OK); + let first_variant_uri = single_variant_uri(first_response).await; + let second_variant_uri = single_variant_uri(second_response).await; + assert_eq!( + proxy_session_id_from_variant_uri(&first_variant_uri), + proxy_session_id_from_variant_uri(&second_variant_uri) + ); + assert_ne!( + access_lease_id_from_variant_uri(&first_variant_uri), + access_lease_id_from_variant_uri(&second_variant_uri) + ); + + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); + let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); + let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); + let now_ms = super::current_time_millis(); + let mut leases = app_state.hls_proxy.access_leases().write().await; + let first_lease = leases.response_snapshot(&first_lease_id, &proxy_session_id, now_ms).expect("first access lease"); + let second_lease = + leases.response_snapshot(&second_lease_id, &proxy_session_id, now_ms).expect("second access lease"); + assert_eq!(first_lease.virtual_id, 1001); + assert_eq!(second_lease.virtual_id, 9007); + assert_eq!(first_lease.stream_ref, "80510"); + assert_eq!(second_lease.stream_ref, "80510"); +} + +#[tokio::test] +async fn hls_virtual_source_resolver_rejects_missing_input_stream_id_with_service_unavailable() { + let input = single_hls_provider_input("missing-origin-id-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let target = Arc::new(test_m3u_hls_share_target()); + let item = + test_m3u_hls_item(&input, 1001, "", "http://account.example.com/live/account-user/account-pass/channel.m3u8"); + cache_test_m3u_hls_item(&app_state, &target, item).await; + + let status = super::resolve_hls_virtual_source_for_target(&app_state, &target, 1001) + .await + .expect_err("missing input stream identity must fail safely"); + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn hls_cache_entry_uses_legacy_path_when_target_hls_share_disabled() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + configure_default_test_server(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + user.password = "hls-pass".to_string(); + let input = test_hls_input(); + let target = test_hls_share_target(false); + let original_hls_entry_path = super::build_virtual_hls_entry_path(&target, &input, &user, 12345); + + let response = super::handle_hls_stream_request( + &test_fingerprint(), + &app_state, + &user, + &target, + None, + None, + "http://origin.example.com/live/user/pass/12345.m3u8", + None, + test_hls_entry_stream_context(12345, "80510", None), + &input, + &HeaderMap::new(), + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + &original_hls_entry_path, + ) + .await + .into_response(); + + let location = response.headers().get(header::LOCATION).and_then(|value| value.to_str().ok()).unwrap_or(""); + assert!(!location.contains("/hls/shared/live/")); + assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); + assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); +} + +#[tokio::test] +async fn legacy_hls_token_route_renders_channel_unavailable_inline_when_target_hls_share_enabled() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + app_state.app_config.config.store(Arc::new(Config { + custom_stream_response_enabled: true, + reverse_proxy: Some(ReverseProxyConfig::from(&ReverseProxyConfigDto { + hls_cache: Some(HlsCacheConfigDto::default()), + ..Default::default() + })), + ..Default::default() + })); + configure_default_test_server(&app_state); + enable_channel_unavailable_custom_response(&app_state); + let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); + let input = test_hls_input(); + let target = test_hls_share_target(true); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + let encrypt_secret = app_state.get_encrypt_secret(); + let legacy_manifest = rewrite_hls( + &user, + &RewriteHlsProps { + secret: &encrypt_secret, + base_url: "", + content: "#EXTM3U\n#EXTINF:4.0,\nseg.ts\n", + hls_url: "http://origin.example.com/live/user/pass/12345.m3u8".to_string(), + target_id: target.id, + virtual_id: 12345, + input_id: input.id, + user_token: Some("legacy-session-token"), + }, + ); + let token = legacy_manifest + .lines() + .find_map(|line| line.rsplit_once('/').map(|(_, token)| token.trim().to_string())) + .expect("legacy hls segment token should be rendered"); + + let response = super::hls_api_stream_resolved( + test_fingerprint(), + HeaderMap::new(), + Arc::clone(&app_state), + Arc::clone(&user), + Arc::new(target), + input.id, + 12345, + token, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::LOCATION)); + assert_eq!(response.headers()[header::CONTENT_TYPE], "application/vnd.apple.mpegurl"); + assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn legacy_hls_token_route_with_invalid_token_returns_bad_request_when_target_hls_share_enabled() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let user = app_state.app_config.get_user_credentials("hls-user").expect("test user should exist"); + let input = test_hls_input(); + let target = test_hls_share_target(true); + store_test_sources_with_target(&app_state, input.clone(), target.clone()); + + let response = super::hls_api_stream_resolved( + test_fingerprint(), + HeaderMap::new(), + Arc::clone(&app_state), + user, + Arc::new(target), + input.id, + 12345, + "not-a-valid-legacy-token.ts".to_string(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(app_state.hls_proxy.access_leases().read().await.is_empty()); + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +#[test] +fn cache_enabled_legacy_hls_route_only_allows_existing_m3u_catchup_session() { + assert!(super::legacy_hls_route_allowed_with_cache(true, Some("m3u-catchup|session"), Some("m3u-catchup|session"))); + assert!(super::legacy_hls_route_allowed_with_cache(true, Some("catchup|session"), Some("catchup|session"))); + assert!(!super::legacy_hls_route_allowed_with_cache(true, Some("m3u-catchup|session"), Some("m3u-catchup|other"))); + assert!(!super::legacy_hls_route_allowed_with_cache(true, Some("legacy-session"), Some("legacy-session"))); + assert!(!super::legacy_hls_route_allowed_with_cache(true, None, None)); + assert!(super::legacy_hls_route_allowed_with_cache(false, None, None)); +} + +#[tokio::test] +async fn hls_cache_canonical_manifest_rejects_when_target_hls_share_disabled() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let input = test_hls_input(); + let target = test_hls_share_target(false); + store_test_sources_with_target(&app_state, input, target); + let access_context = HlsAccessContext { + username: "hls-user".to_string(), + user_session_token: "hls-session-token".to_string(), + proxy_session_id: ProxySessionId("proxy-session".to_string()), + input_id: 1, + stream_ref: "12345".to_string(), + virtual_id: 12345, + known_bitrate_bps: None, + lease_id: HlsAccessLeaseId("access-lease".to_string()), + family_key: HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + epg_reference_ts: None, + archive_origin_url: None, + }; + + let Err(err) = + super::resolve_hls_playback_manifest_request_context(&app_state, &access_context, &HeaderMap::new()).await + else { + panic!("disabled target hls sharing should reject canonical cache path"); + }; + + assert_eq!(err, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn hls_cache_entry_leases_update_effective_origin_acquire_policy_for_shared_session() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut soft_user = ProxyUserCredentials::default(); + soft_user.username = "soft-user".to_string(); + soft_user.soft_priority = 20; + let mut normal_user = ProxyUserCredentials::default(); + normal_user.username = "normal-user".to_string(); + normal_user.priority = -5; + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let session_key = origin_source.session_key(); + let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); + + let soft_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &soft_user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Soft), + None, + ) + .await; + assert_eq!(soft_response.status(), StatusCode::OK); + let soft_snapshot = + app_state.hls_proxy.access_lease_session_snapshot(&proxy_session_id, super::current_time_millis()).await; + let soft_policy = soft_snapshot.effective_origin_policy.expect("soft policy"); + assert_eq!(soft_policy.connection_kind, ConnectionKind::Soft); + assert_eq!(soft_policy.priority, soft_user.soft_priority); + + // Different user/family, same shared HLS session. Normal media admission must upgrade + // the future origin-account acquire policy without changing the shared session identity. + let normal_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &normal_user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + assert_eq!(normal_response.status(), StatusCode::OK); + let normal_snapshot = + app_state.hls_proxy.access_lease_session_snapshot(&proxy_session_id, super::current_time_millis()).await; + let normal_policy = normal_snapshot.effective_origin_policy.expect("normal policy"); + assert_eq!(normal_policy.connection_kind, ConnectionKind::Normal); + assert_eq!(normal_policy.priority, normal_user.priority); + + let (session, _) = app_state + .hls_proxy + .get_or_create_session_with_source_and_outcome( + session_key, + super::build_hls_origin_source(&input, "12345"), + &app_state.get_encrypt_secret(), + super::current_time_millis(), + ) + .await; + app_state + .hls_proxy + .sync_session_access_lease_count_and_detach_if_needed( + &app_state.active_users, + &app_state.active_provider, + &session, + &proxy_session_id, + super::current_time_millis(), + ) + .await; + let session_policy = session.read().await.effective_origin_acquire_policy_or_default(); + assert_eq!(session_policy.connection_kind, ConnectionKind::Normal); + assert_eq!(session_policy.priority, normal_user.priority); +} + +#[tokio::test] +async fn hls_entry_origin_reservation_requires_real_provider_handle() { + let app_state = test_app_state(); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = single_hls_provider_input("missing-provider"); + + let reservation = super::try_reserve_hls_entry_origin_account_for_redirect( + &app_state, + &test_fingerprint(), + &user, + &input, + 12345, + "http://origin.example.com/live/source-user/source-pass/12345.m3u8", + "hls-session-token", + "hls-cache:test-session", + super::hls_origin_account_reservation_ttl_secs_fallback(), + UserConnectionPermission::Allowed, + ConnectionKind::Normal, + false, + ) + .await; + + assert!(reservation.is_none(), "provisioning redirect must not use an exhausted/counter-only check"); +} + +#[tokio::test] +async fn hls_entry_origin_reservation_sets_owner_reservation_before_redirect() { + let input = single_hls_provider_input("available-provider"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let session_owner = "hls-cache:test-session"; + + let reservation = super::try_reserve_hls_entry_origin_account_for_redirect( + &app_state, + &test_fingerprint(), + &user, + &input, + 12345, + "http://account.example.com/live/account-user/account-pass/12345.m3u8", + "hls-session-token", + session_owner, + super::hls_origin_account_reservation_ttl_secs_fallback(), + UserConnectionPermission::Allowed, + ConnectionKind::Normal, + false, + ) + .await + .expect("provider reservation should be acquired before provisioning redirect"); + + assert_eq!(reservation.request_url, "http://account.example.com/live/account-user/account-pass/12345.m3u8"); + assert!(reservation.selected_provider_config.is_some()); + assert!(app_state.active_users.active_streams().await.is_empty()); + app_state.connection_manager.release_provider_handle(reservation.provider_handle).await; + + assert!( + app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &test_addr_with_port(55251), + false, + 0, + ConnectionKind::Normal, + Some("other-owner"), + ) + .await + .is_none(), + "reserved provider must stay blocked for other HLS sessions" + ); + + let same_owner_handle = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &test_addr_with_port(55252), + false, + 0, + ConnectionKind::Normal, + Some(session_owner), + ) + .await; + assert!(same_owner_handle.is_some(), "reserved provider must be reusable by the same HLS session owner"); + app_state.connection_manager.release_provider_handle(same_owner_handle).await; +} + +#[tokio::test] +async fn hls_virtual_entry_reservation_uses_input_stream_id_for_shared_session_owner() { + let input = single_hls_provider_input("origin-id-reservation-input"); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + let target = Arc::new(test_m3u_hls_share_target()); + let item = test_m3u_hls_item( + &input, + 1001, + "80510", + "http://account.example.com/live/account-user/account-pass/80510.m3u8", + ); + cache_test_m3u_hls_item(&app_state, &target, item).await; + let stream_identity = super::HlsEntryStreamIdentity::new(1001, "80510").expect("input stream identity"); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + + assert!( + super::try_reserve_hls_virtual_entry_origin_account_for_redirect( + &app_state, + &test_fingerprint(), + &user, + &target, + &input, + &stream_identity, + ) + .await + ); + + let expected_key = HlsSessionKey::new(input.id, "80510"); + let expected_proxy_session_id = build_proxy_session_id(&expected_key, &app_state.get_encrypt_secret()); + let expected_owner = crate::api::model::build_hls_origin_session_owner(&expected_proxy_session_id); + let same_owner_handle = app_state + .active_provider + .acquire_connection_with_grace_for_session( + &input.name, + &test_addr_with_port(55253), + false, + 0, + ConnectionKind::Normal, + Some(&expected_owner), + ) + .await; + assert!(same_owner_handle.is_some(), "reservation must be owned by input:1|hls|80510, not virtual_id=1001"); + app_state.connection_manager.release_provider_handle(same_owner_handle).await; +} + +#[tokio::test] +async fn hls_cache_entry_creates_new_lease_for_same_user_session_and_proxy_session() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + + let first_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let first_variant_uri = single_variant_uri(first_response).await; + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); + let first_access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); + let first_session_token = access_lease_session_token(&app_state, &proxy_session_id, &first_access_lease_id).await; + + let second_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let second_variant_uri = single_variant_uri(second_response).await; + assert_ne!( + access_lease_id_from_variant_uri(&first_variant_uri), + access_lease_id_from_variant_uri(&second_variant_uri) + ); + let second_access_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); + let second_session_token = access_lease_session_token(&app_state, &proxy_session_id, &second_access_lease_id).await; + assert_ne!(first_session_token, second_session_token); +} + +#[tokio::test] +async fn hls_cache_entry_creates_new_lease_after_manifest_touch() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + + let first_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let first_variant_uri = single_variant_uri(first_response).await; + let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); + let first_session_token = access_lease_session_token(&app_state, &proxy_session_id, &first_lease_id).await; + let now_ms = super::current_time_millis(); + + assert!(matches!( + app_state + .hls_proxy + .touch_manifest_access_lease( + &first_lease_id, + &proxy_session_id, + now_ms, + None, + Some(super::HlsAccessLeasePendingDeadline::Bootstrap { + deadline_ms: now_ms.saturating_add(super::hls_pending_bootstrap_window_ms(&app_state)), + }), + super::hls_access_lease_ttl_ms(&app_state), + ) + .await, + HlsAccessLeaseTouch::Touched { .. } + )); + + let second_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let second_variant_uri = single_variant_uri(second_response).await; + + assert_ne!( + access_lease_id_from_variant_uri(&first_variant_uri), + access_lease_id_from_variant_uri(&second_variant_uri) + ); + let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); + let second_session_token = access_lease_session_token(&app_state, &proxy_session_id, &second_lease_id).await; + assert_ne!(first_session_token, second_session_token); +} + +#[tokio::test] +async fn hls_cache_entry_does_not_reuse_activated_access_lease() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + + let first_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let first_variant_uri = single_variant_uri(first_response).await; + let first_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&first_variant_uri).to_string()); + let proxy_session_id = ProxySessionId(proxy_session_id_from_variant_uri(&first_variant_uri).to_string()); + let now_ms = super::current_time_millis(); + assert!(app_state + .hls_proxy + .activate_access_lease( + &first_lease_id, + &proxy_session_id, + now_ms, + HlsAccessLeaseTiming { + active_window_ms: 5_000, + valid_window_ms: super::hls_access_lease_ttl_ms(&app_state), + }, + ) + .await + .is_activated()); + + let second_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let second_variant_uri = single_variant_uri(second_response).await; + let second_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&second_variant_uri).to_string()); + + assert_ne!(first_lease_id, second_lease_id); + let first_session_token = access_lease_session_token(&app_state, &proxy_session_id, &first_lease_id).await; + let second_session_token = access_lease_session_token(&app_state, &proxy_session_id, &second_lease_id).await; + assert_ne!(first_session_token, second_session_token); + assert!( + app_state + .hls_proxy + .touch_access_lease( + &first_lease_id, + super::current_time_millis(), + HlsAccessLeaseTiming { + active_window_ms: 5_000, + valid_window_ms: super::hls_access_lease_ttl_ms(&app_state), + }, + ) + .await + ); +} + +#[tokio::test] +async fn hls_cache_entry_ignores_existing_pending_lease_for_new_playback() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let proxy_session_id = build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); + let old_lease_id = HlsAccessLeaseId("old-pending-lease".to_string()); + let old_session_token = "old-hls-session-token"; + let old_issued_at_ms = super::current_time_millis().saturating_sub(6_000); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + old_lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key), + proxy_session_id.clone(), + "hls-user".to_string(), + old_session_token.to_string(), + 1, + "12345".to_string(), + 12345, + old_issued_at_ms, + super::hls_access_lease_ttl_ms(&app_state), + )) + .await; + + let response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let variant_uri = single_variant_uri(response).await; + let new_lease_id = HlsAccessLeaseId(access_lease_id_from_variant_uri(&variant_uri).to_string()); + let new_session_token = access_lease_session_token(&app_state, &proxy_session_id, &new_lease_id).await; + + assert_ne!(old_lease_id, new_lease_id); + assert_ne!(old_session_token, new_session_token); +} + +#[tokio::test] +async fn hls_cache_parallel_real_playbacks_same_virtual_id_register_distinct_streams() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = ConfigInput { id: 1, name: Arc::from("test-input"), ..Default::default() }; + let request_url = "http://origin.example.com/live/user/pass/12345.m3u8"; + let origin_source = super::build_hls_origin_source(&input, "12345"); + let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; + + let first_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source.clone(), + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let first_variant_uri = single_variant_uri(first_response).await; + let first_lease_id = access_lease_id_from_variant_uri(&first_variant_uri); + assert_eq!(proxy_session_id_from_variant_uri(&first_variant_uri), proxy_session_id); + let proxy_session = ProxySessionId(proxy_session_id.clone()); + let first_session_token = + access_lease_session_token(&app_state, &proxy_session, &HlsAccessLeaseId(first_lease_id.to_string())).await; + publish_ready_test_manifest_for_lease( + &app_state, + &proxy_session, + &HlsAccessLeaseId(first_lease_id.to_string()), + 4_000, + ) + .await; + let first_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{first_lease_id}/000123.ts"); + assert_eq!(get_status(Arc::clone(&app_state), &first_segment_uri).await, StatusCode::OK); + + let second_response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 12345, + None, + None, + None, + request_url, + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + let second_variant_uri = single_variant_uri(second_response).await; + let second_lease_id = access_lease_id_from_variant_uri(&second_variant_uri); + assert_ne!(first_lease_id, second_lease_id); + assert_eq!(proxy_session_id_from_variant_uri(&second_variant_uri), proxy_session_id); + let second_session_token = + access_lease_session_token(&app_state, &proxy_session, &HlsAccessLeaseId(second_lease_id.to_string())).await; + assert_ne!(first_session_token, second_session_token); + publish_ready_test_manifest_for_lease( + &app_state, + &proxy_session, + &HlsAccessLeaseId(second_lease_id.to_string()), + 4_000, + ) + .await; + let second_segment_uri = format!("/hls/shared/live/{proxy_session_id}/{second_lease_id}/000123.ts"); + assert_eq!(get_status(Arc::clone(&app_state), &second_segment_uri).await, StatusCode::OK); + + let streams = app_state.active_users.active_streams().await; + assert_eq!(streams.len(), 2); + let first_stream = streams + .iter() + .find(|stream| stream.session_token.as_deref() == Some(first_session_token.as_str())) + .expect("first stream should be registered"); + let second_stream = streams + .iter() + .find(|stream| stream.session_token.as_deref() == Some(second_session_token.as_str())) + .expect("second stream should be registered"); + let shared_stream_id = super::hls_cache_shared_stream_id(&proxy_session); + assert_ne!(first_stream.session_token, second_stream.session_token); + assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(second_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); + assert_eq!(second_stream.channel.shared_joined_existing, Some(true)); +} + +#[tokio::test] +async fn hls_cache_entry_master_playlist_for_xtream_uses_stream_ref_session_identity() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = + ConfigInput { id: 7, name: Arc::from("xtream-input"), input_type: InputType::Xtream, ..ConfigInput::default() }; + let origin_source = super::build_hls_origin_source(&input, "80510"); + let expected_proxy_session_id = + build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); + + let response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 80510, + None, + None, + None, + "http://origin.example.com/live/user/pass/80510.m3u8", + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let variant_uri = single_variant_uri(response).await; + assert!(variant_uri.starts_with(&format!("/hls/shared/live/{}/", expected_proxy_session_id.0))); + assert!(variant_uri.ends_with("/manifest.m3u8")); + assert!(app_state.hls_proxy.sessions().get_by_key(&HlsSessionKey::new(7, "80510")).await.is_none()); + assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); +} + +#[tokio::test] +async fn hls_cache_entry_master_playlist_for_m3u_uses_stream_ref_session_identity() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let mut user = ProxyUserCredentials::default(); + user.username = "hls-user".to_string(); + let input = + ConfigInput { id: 9, name: Arc::from("m3u-input"), input_type: InputType::M3u, ..ConfigInput::default() }; + let origin_source = super::build_hls_origin_source(&input, "70001"); + let expected_proxy_session_id = + build_proxy_session_id(&origin_source.session_key(), &app_state.get_encrypt_secret()); + + let response = super::create_hls_cache_entry_master_playlist_response( + &app_state, + &test_fingerprint(), + &user, + origin_source, + 70001, + None, + None, + None, + "http://media.example.com/channel/playlist.m3u8", + &input, + UserConnectionPermission::Allowed, + Some(ConnectionKind::Normal), + Some("/iptv"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let variant_uri = single_variant_uri(response).await; + assert!(variant_uri.starts_with(&format!("/iptv/hls/shared/live/{}/", expected_proxy_session_id.0))); + assert!(variant_uri.ends_with("/manifest.m3u8")); + assert!(app_state.hls_proxy.sessions().get_by_key(&HlsSessionKey::new(9, "70001")).await.is_none()); + assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); +} + +#[tokio::test] +async fn hls_proxy_manifest_invalid_token_starts_no_origin_work() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + + let response = get_response( + Arc::clone(&app_state), + "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/not-a-valid-token/manifest.m3u8", + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(app_state.hls_proxy.metrics().snapshot().refresh_started, 0); + assert!(app_state.hls_proxy.sessions().is_empty().await); +} + +async fn prepare_server_path_manifest_session(app_state: &Arc) -> (HlsSessionHandle, ProxySessionId) { + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = { + let mut session = session.write().await; + session.origin_refresh.next_fetch_allowed_at_ms = u64::MAX; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + let map_id = ProxyMapId(0); + let mut map = MapEntry::new( + &session.proxy_session_id, + map_id, + OriginMapKey { + origin_epoch: 0, + resolved_origin_uri: "http://origin.example.com/init.mp4".to_string(), + byte_range: None, + }, + "mp4".to_string(), + ); + map.status = MapCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }; + session.maps.insert(map_id, map); + let mut segment = test_segment_entry( + &session.proxy_session_id, + 123, + SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, + ); + segment.map_ref = Some(map_id); + session.segments.insert(123, segment); + session.advance_media_readiness_generation(); + session.last_rendered_manifest = Some(RenderedManifest { + body: format!( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:123\n#EXT-X-MAP:URI=\"/hls/shared/live/{proxy_session_id}/{}/map/000000.mp4\"\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_id}/{}/000123.ts\n", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ), + first_proxy_seq: 123, + last_proxy_seq: 123, + playlist_duration_ms: 4_000, + valid_until_ms: rendered_at_ms.saturating_add(4_000), + render_gap_segments: 0, + rendered_at_ms, + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![123], + }); + record_test_normal_manifest_commit(&mut session, rendered_at_ms); + ProxySessionId(proxy_session_id) + }; + (session, proxy_session_id) +} + +#[tokio::test] +async fn hls_cache_manifest_response_applies_current_users_server_path_without_mutating_session_body() { + let input_name = Arc::::from("test-input"); + let input = ConfigInput { + id: 1, + name: Arc::clone(&input_name), + input_type: InputType::Xtream, + url: "http://origin.example.com".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + max_connections: 1, + enabled: true, + ..ConfigInput::default() + }; + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + enable_hls_cache(&app_state); + create_active_hls_user_session(&app_state).await; + let (session, proxy_session_id) = prepare_server_path_manifest_session(&app_state).await; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let access_context = test_hls_access_context(proxy_session_id.clone(), access_lease_id.clone()); + prepare_pending_test_hls_access_lease(&app_state, &proxy_session_id, &access_lease_id).await; + + let response = super::try_hls_cache_canonical_manifest_response( + &app_state, + &test_fingerprint(), + &access_context, + &proxy_session_id, + &access_lease_id, + HlsAccessLeaseState::Pending, + super::HlsCacheManifestOrigin { + raw_request_url: "http://origin.example.com/live/user/pass/12345.m3u8", + session_entry_url: super::HlsOriginEntryUrl::direct_http( + "http://origin.example.com/live/user/pass/12345.m3u8", + ), + input: &input, + origin_source: super::build_hls_origin_source(&input, "12345"), + }, + HeaderMap::new(), + Some("/iptv"), + "/live/hls-user/hls-pass/12345.m3u8", + super::HlsManifestRefreshOrdering::Background, + ) + .await + .expect("hls cache should handle valid live hls entrypoint"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest should be utf8"); + + assert!(body.contains(&format!("/iptv/hls/shared/live/{}/", proxy_session_id.0))); + assert!(body.contains("/map/000000.mp4")); + assert!(body.contains("/000123.ts")); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + let stored_body = session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body.clone(); + assert!(stored_body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert!(stored_body.contains(&format!("/hls/shared/live/{}/", proxy_session_id.0))); + assert!(!stored_body.contains("/iptv/hls/shared/live/")); + assert!(!stored_body.contains(&access_lease_id.0)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); +} + +fn transient_manifest_body(proxy_session_id: &str) -> String { + transient_manifest_body_from_sequence(proxy_session_id, 100, 6) +} + +fn transient_manifest_body_from_sequence(proxy_session_id: &str, first_sequence: u64, count: usize) -> String { + let mut body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); + for index in 0..count { + let sequence = first_sequence.saturating_add(u64::try_from(index).expect("test sequence index fits u64")); + body.push_str("#EXTINF:10.0,\n"); + let _ = writeln!( + body, + "/hls/shared/live/{proxy_session_id}/{}/r/seg{sequence}.ts", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ); + } + body +} + +fn media_uri_count(body: &str) -> usize { + body.lines().filter(|line| !line.is_empty() && !line.starts_with('#')).count() +} + +fn normal_manifest_body(proxy_session_id: &str) -> String { normal_manifest_body_from_sequence(proxy_session_id, 0, 6) } + +fn normal_manifest_body_from_sequence(proxy_session_id: &str, first_sequence: u64, count: usize) -> String { + let mut body = format!("#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); + for index in 0..count { + let sequence = first_sequence.saturating_add(u64::try_from(index).expect("test sequence index fits u64")); + body.push_str("#EXTINF:10.0,\n"); + let _ = writeln!( + body, + "/hls/shared/live/{proxy_session_id}/{}/{sequence:06}.ts", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ); + } + body +} + +fn store_normal_manifest_body(session: &mut HlsSession, body: String, rendered_at_ms: u64) { + store_normal_manifest_body_range(session, body, 0, 6, rendered_at_ms); +} + +fn record_test_normal_manifest_commit(session: &mut HlsSession, rendered_at_ms: u64) { + let identity = session + .next_manifest_commit_identity(rendered_at_ms) + .expect("test manifest commit generation remains available"); + session.record_normal_manifest_commit_identity(identity); +} + +fn store_normal_manifest_body_range( + session: &mut HlsSession, + body: String, + first_proxy_seq: u64, + count: usize, + rendered_at_ms: u64, +) { + let last_proxy_seq = + first_proxy_seq.saturating_add(u64::try_from(count.saturating_sub(1)).expect("test count fits u64")); + let proxy_session_id = session.proxy_session_id.clone(); + for proxy_seq in first_proxy_seq..=last_proxy_seq { + let mut entry = test_segment_entry( + &proxy_session_id, + proxy_seq, + SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, + ); + entry.duration_ms = 10_000; + session.segments.insert(proxy_seq, entry); + } + session.advance_media_readiness_generation(); + session.last_rendered_manifest = Some(RenderedManifest { + body, + first_proxy_seq, + last_proxy_seq, + playlist_duration_ms: 60_000, + valid_until_ms: rendered_at_ms.saturating_add(60_000), + render_gap_segments: 0, + rendered_at_ms, + discontinuity_sequence: 0, + target_duration_ms: 10_000, + segment_proxy_seqs: (first_proxy_seq..=last_proxy_seq).collect(), + }); + record_test_normal_manifest_commit(session, rendered_at_ms); +} + +async fn try_test_hls_cached_manifest_response( + app_state: &Arc, + session: &HlsSessionHandle, + access_lease_id: &HlsAccessLeaseId, + access_lease_state: HlsAccessLeaseState, + strip: &StripConfig, + server_path: Option<&str>, + options: super::HlsCachedManifestOptions, +) -> Option { + let proxy_session_id = session.read().await.proxy_session_id.clone(); + let now_ms = super::current_time_millis(); + if app_state.hls_proxy.access_lease_response_snapshot(access_lease_id, &proxy_session_id, now_ms).await.is_none() { + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + access_lease_id.clone(), + HlsPlaybackFamilyKey::new("test-user", "manifest-test-client"), + proxy_session_id, + "test-user".to_string(), + "manifest-test-session".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + 60_000, + )) + .await; + } + super::try_hls_cached_manifest_response( + app_state, + session, + access_lease_id, + access_lease_state, + strip, + server_path, + options, + super::HlsRuntimeBandwidthLearningContext::Disabled, + ) + .await +} + +#[test] +fn repeated_speculative_strip_candidates_yield_one_committed_applied_diagnostic() { + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let body = normal_manifest_body("proxy-session"); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + let candidates = (0..8) + .map(|_| { + super::materialize_shared_hls_access_manifest( + &body, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + super::HlsManifestWindowPolicy::ApplyLiveWindow, + "normal", + None, + ) + }) + .collect::>(); + + assert_eq!(candidates.len(), 8); + let candidate_count = candidates.len(); + let diagnostics = candidates + .iter() + .enumerate() + .filter_map(|(index, candidate)| { + super::hls_initial_strip_publication_diagnostic( + if index.saturating_add(1) == candidate_count { + super::HlsInitialStripPublicationStatus::Committed + } else { + super::HlsInitialStripPublicationStatus::NotCommitted + }, + HlsAccessLeaseState::Pending, + candidate, + ) + }) + .collect::>(); + + assert_eq!( + diagnostics, + vec![super::HlsInitialStripPublicationDiagnostic::Applied { + mode: "normal", + strip_mode: "segments", + configured: 3, + effective: 3, + visible_segments: 3, + }] + ); +} + +#[test] +fn committed_pending_strip_disabled_yields_one_skipped_diagnostic() { + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let materialized = super::materialize_shared_hls_access_manifest( + &normal_manifest_body("proxy-session"), + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 0 }, + super::HlsManifestWindowPolicy::ApplyLiveWindow, + "normal", + None, + ); + + let diagnostics = [super::hls_initial_strip_publication_diagnostic( + super::HlsInitialStripPublicationStatus::Committed, + HlsAccessLeaseState::Pending, + &materialized, + ) + .expect("committed strip diagnostic")]; + + assert_eq!( + diagnostics, + [super::HlsInitialStripPublicationDiagnostic::Skipped { + mode: "normal", + reason: crate::api::model::hls_cache::initial_strip::HlsInitialStripSkipReason::StripDisabled, + visible_segments: 6, + }] + ); + assert_eq!(media_uri_count(&materialized.body), 6); +} + +#[test] +fn pending_playlist_type_manifest_ignores_three_to_six_window_and_strip() { + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + for segment_count in [2, 8] { + let body = transient_manifest_body_from_sequence("proxy-session", 100, segment_count).replacen( + "#EXTM3U\n", + "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT\n", + 1, + ); + let materialized = super::materialize_shared_hls_access_manifest( + &body, + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 5 }, + super::HlsManifestWindowPolicy::PreserveFullManifest, + "transient", + None, + ); + + assert_eq!(media_uri_count(&materialized.body), segment_count); + assert!(materialized.body.contains("#EXT-X-PLAYLIST-TYPE:EVENT")); + assert!(!materialized.body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert_eq!( + materialized.initial_strip_outcome, + Some(super::HlsInitialStripOutcome::Skipped { + reason: super::HlsInitialStripSkipReason::ManifestSemanticsPreserveFullManifest, + visible_segments: segment_count, + }) + ); + } +} + +#[test] +fn pending_endlist_only_manifest_keeps_complete_body_despite_strip() { + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let mut body = transient_manifest_body_from_sequence("proxy-session", 100, 8); + body.push_str("#EXT-X-ENDLIST\n"); + let window_policy = + crate::processing::parser::hls::origin_manifest::parse_manifest_semantics(&body).window_policy(); + assert_eq!(window_policy, super::HlsManifestWindowPolicy::PreserveFullManifest); + + let materialized = super::materialize_shared_hls_access_manifest( + &body, + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 5 }, + window_policy, + "transient", + None, + ); + + assert_eq!(media_uri_count(&materialized.body), 8); + assert!(materialized.body.contains("#EXT-X-ENDLIST")); + assert_eq!( + materialized.initial_strip_outcome, + Some(super::HlsInitialStripOutcome::Skipped { + reason: super::HlsInitialStripSkipReason::ManifestSemanticsPreserveFullManifest, + visible_segments: 8, + }) + ); +} + +#[test] +fn committed_activated_manifest_yields_one_lease_state_skip_diagnostic() { + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let materialized = super::materialize_shared_hls_access_manifest( + &normal_manifest_body("proxy-session"), + &access_lease_id, + HlsAccessLeaseState::Activated, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + super::HlsManifestWindowPolicy::ApplyLiveWindow, + "normal", + None, + ); + + let diagnostics = [super::hls_initial_strip_publication_diagnostic( + super::HlsInitialStripPublicationStatus::Committed, + HlsAccessLeaseState::Activated, + &materialized, + ) + .expect("committed strip diagnostic")]; + + assert_eq!( + diagnostics, + [super::HlsInitialStripPublicationDiagnostic::SkippedForLeaseState { + mode: "normal", + reason: super::HlsInitialStripLeaseSkipReason::LeaseActivated, + }] + ); + assert_eq!(media_uri_count(&materialized.body), 6); +} + +#[tokio::test(start_paused = true)] +async fn pending_strip_admission_timeout_does_not_commit_speculative_candidate() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.clone(); + let rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id.0), rendered_at_ms); + session.segments.get_mut(&1).expect("visible test segment").status = SegmentCacheStatus::Discovered; + session.advance_media_readiness_generation(); + session.mark_authorized_media_access(rendered_at_ms); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 3 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::from_millis(75)), + ) + .await + .expect("timeout response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, super::current_time_millis()) + .await + .expect("pending lease remains available"); + assert!(lease.last_manifest_snapshot.is_none()); +} + +async fn publish_ready_test_manifest_for_lease( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, + target_duration_ms: u64, +) { + let session = + app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await.expect("test session exists"); + let (proxy_seq, duration_ms) = { + let session = session.read().await; + session + .segments + .iter() + .find_map(|(proxy_seq, entry)| { + matches!(entry.status, SegmentCacheStatus::Ready { .. }).then_some((*proxy_seq, entry.duration_ms)) + }) + .expect("READY test segment") + }; + let now_ms = super::current_time_millis(); + let publication_guard = app_state + .hls_proxy + .prepare_access_lease_manifest_publication(access_lease_id, proxy_session_id, now_ms) + .await + .expect("test lease accepts publication"); + let snapshot = HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, + source_commit_identity: HlsManifestCommitIdentity::new(now_ms), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 0, + delivered_at_ms: now_ms, + first_proxy_seq: proxy_seq, + last_proxy_seq: proxy_seq, + visible_segments: Arc::from([HlsLeaseManifestSegment { + proxy_seq, + duration_ms, + uri: format!("/hls/shared/live/{}/{}/{proxy_seq:06}.ts", proxy_session_id.0, access_lease_id.0).into(), + discontinuity_before: false, + map_ref_ready: true, + encryption: None, + }]), + discontinuity_sequence: 0, + target_duration_ms: target_duration_ms.max(duration_ms), + playlist_duration_ms: duration_ms, + last_visible_media_end_ms: duration_ms, + active_map: None, + active_encryption: None, + container: HlsMediaContainer::MpegTs, + }; + assert!(app_state + .hls_proxy + .commit_access_lease_manifest_publication( + access_lease_id, + proxy_session_id, + publication_guard, + snapshot, + now_ms, + ) + .await + .is_committed()); +} + +async fn prepare_user_exhausted_terminal_bundle(app_state: &Arc, target_duration_ms: u64) { + let response = app_state.app_config.custom_stream_response.load_full().expect("runtime custom responses"); + let asset = response + .user_connections_exhausted + .as_ref() + .and_then(|buffer| snapshot_terminal_media_asset(buffer).ok()) + .expect("valid user-exhausted terminal asset"); + let key = prepared_terminal_bundle_key(&asset, target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); + let state = + app_state.hls_proxy.start_prepared_terminal_bundle(asset, target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); + let state = match state { + HlsPreparedTerminalBundleState::Preparing { .. } => app_state + .hls_proxy + .wait_for_prepared_terminal_bundle(key) + .await + .expect("user-exhausted terminal bundle completion"), + state => state, + }; + assert!(matches!( + state, + HlsPreparedTerminalBundleState::Ready { ref bundle } if bundle.key == key + )); +} + +struct RuntimePolicyEndpointFixture { + _temp_dir: tempfile::TempDir, + app_state: Arc, + proxy_session_id: ProxySessionId, + lease_id: HlsAccessLeaseId, + manifest_uri: String, + live_segment_uri: String, +} + +async fn assert_runtime_policy_base_timing( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, + phase: &str, +) { + let session = + app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await.expect("runtime policy session"); + let manifest = app_state + .hls_proxy + .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) + .await + .and_then(|lease| lease.last_manifest_snapshot) + .expect("runtime policy manifest"); + let evidence = prepare_terminal_base_evidence( + &session, + app_state.hls_proxy.segment_cache(), + &manifest, + super::current_time_millis(), + ) + .await; + assert!( + evidence.timing().is_some(), + "runtime policy base timing {phase}: {}", + evidence.track_evidence_reason_code() + ); + evidence.release(); +} + +async fn serve_and_wait_runtime_policy_base_segment( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, + live_segment_uri: &str, +) { + let initial_segment = get_response(Arc::clone(app_state), live_segment_uri, None).await; + assert_eq!(initial_segment.status(), StatusCode::OK); + assert!(!response_body(initial_segment).await.is_empty()); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let completed = app_state + .hls_proxy + .access_lease_response_snapshot(lease_id, proxy_session_id, super::current_time_millis()) + .await + .and_then(|lease| lease.playback_cursor.highest_contiguous_completed_proxy_seq); + if completed == Some(123) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("initial live segment completion"); +} + +async fn runtime_policy_endpoint_fixture(publish_manifest: bool) -> RuntimePolicyEndpointFixture { + const TARGET_DURATION_MS: u64 = 12_000; + + let temp_dir = tempfile::tempdir().expect("runtime policy cache tempdir"); + let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300))); + enable_runtime_policy_custom_responses(&app_state); + let live_bytes = + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")); + let proxy_session_id = ProxySessionId(map_ready_segment_without_lease(&app_state, 123, "ts", live_bytes).await); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id) + .await + .expect("runtime policy session"); + session.write().await.segments.get_mut(&123).expect("runtime policy base segment").duration_ms = TARGET_DURATION_MS; + let lease_id = HlsAccessLeaseId(grant_hls_proxy_lease(&app_state, &proxy_session_id.0).await); + if publish_manifest { + publish_ready_test_manifest_for_lease(&app_state, &proxy_session_id, &lease_id, TARGET_DURATION_MS).await; + let target_duration_ms = app_state + .hls_proxy + .access_lease_response_snapshot(&lease_id, &proxy_session_id, super::current_time_millis()) + .await + .and_then(|lease| lease.last_manifest_snapshot) + .map(|manifest| manifest.target_duration_ms) + .expect("published runtime policy target duration"); + prepare_user_exhausted_terminal_bundle(&app_state, target_duration_ms).await; + } + let manifest_uri = format!("/hls/shared/live/{}/{}/manifest.m3u8", proxy_session_id.0, lease_id.0); + let live_segment_uri = format!("/hls/shared/live/{}/{}/000123.ts", proxy_session_id.0, lease_id.0); + if publish_manifest { + assert_runtime_policy_base_timing(&app_state, &proxy_session_id, &lease_id, "before serve").await; + } + serve_and_wait_runtime_policy_base_segment(&app_state, &proxy_session_id, &lease_id, &live_segment_uri).await; + if publish_manifest { + assert_runtime_policy_base_timing(&app_state, &proxy_session_id, &lease_id, "after serve").await; + } + + RuntimePolicyEndpointFixture { + _temp_dir: temp_dir, + app_state, + proxy_session_id, + lease_id, + manifest_uri, + live_segment_uri, + } +} + +async fn wait_for_runtime_policy_terminal_plan(fixture: &RuntimePolicyEndpointFixture) -> Arc { + let plan = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(HlsLeasePlaybackMode::TerminalTail(plan)) = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot( + &fixture.lease_id, + &fixture.proxy_session_id, + super::current_time_millis(), + ) + .await + .map(|lease| lease.playback_mode) + { + return plan; + } + tokio::task::yield_now().await; + } + }) + .await; + if let Ok(plan) = plan { + return plan; + } + let lease = fixture + .app_state + .hls_proxy + .access_lease_response_snapshot(&fixture.lease_id, &fixture.proxy_session_id, super::current_time_millis()) + .await; + let state = lease.as_ref().map_or("missing", |lease| lease.state.as_log_value()); + let playback = lease.as_ref().map_or("missing", |lease| match lease.playback_mode { + HlsLeasePlaybackMode::Live => "live", + HlsLeasePlaybackMode::TerminalTail(_) => "terminal-tail", + HlsLeasePlaybackMode::TerminalUnavailable { .. } => "terminal-unavailable", + HlsLeasePlaybackMode::Ended => "ended", + }); + panic!( + "runtime policy terminal owner deadline: state={state} playback={playback} owners={}", + fixture.app_state.hls_proxy.terminal_pending().owner_count() + ); +} + +#[tokio::test] +async fn hls_cache_pending_normal_manifest_applies_initial_strip_without_mutating_shared_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); + session.mark_authorized_media_access(rendered_at_ms); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + let stored_before = session.read().await.last_rendered_manifest.as_ref().expect("normal manifest").body.clone(); + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("normal manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); + assert!(body.contains("/000000.ts")); + assert!(body.contains("/000001.ts")); + assert!(body.contains("/000002.ts")); + assert!(!body.contains("/000003.ts")); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert_eq!(session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body, stored_before); + assert_eq!(media_uri_count(&stored_before), 6); +} + +#[tokio::test] +async fn hls_cache_idle_normal_manifest_applies_initial_strip_without_mutating_shared_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); + session.mark_authorized_media_access(rendered_at_ms); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + let stored_before = session.read().await.last_rendered_manifest.as_ref().expect("normal manifest").body.clone(); + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Idle, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("idle normal manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:0\n")); + assert!(body.contains("/000000.ts")); + assert!(body.contains("/000001.ts")); + assert!(body.contains("/000002.ts")); + assert!(!body.contains("/000003.ts")); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert_eq!(session.read().await.last_rendered_manifest.as_ref().expect("stored manifest").body, stored_before); + assert_eq!(media_uri_count(&stored_before), 6); +} + +#[tokio::test] +async fn hls_cache_activated_normal_manifest_skips_initial_strip() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); + session.mark_authorized_media_access(rendered_at_ms); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("normal manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 6); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); +} + +#[tokio::test] +async fn hls_cache_fresh_required_normal_manifest_does_not_serve_stale_committed_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let rendered_at_ms = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id), rendered_at_ms); + session.mark_authorized_media_access(rendered_at_ms); + rendered_at_ms + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::ZERO).requiring_newer_manifest(rendered_at_ms), + ) + .await; + + assert!(response.is_none()); +} + +#[tokio::test] +async fn hls_cache_fresh_required_normal_manifest_waits_for_newer_commit() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let (proxy_session_id, old_rendered_at_ms) = { + let mut session = session.write().await; + let proxy_session_id = session.proxy_session_id.clone(); + let old_rendered_at_ms = super::current_time_millis(); + store_normal_manifest_body(&mut session, normal_manifest_body(&proxy_session_id.0), old_rendered_at_ms); + session.origin_refresh.in_flight = true; + session.mark_authorized_media_access(old_rendered_at_ms); + (proxy_session_id, old_rendered_at_ms) + }; + let session_for_commit = Arc::clone(&session); + let proxy_session_for_body = proxy_session_id.0.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let mut session = session_for_commit.write().await; + let rendered_at_ms = super::current_time_millis(); + let mut entry = test_segment_entry( + &session.proxy_session_id, + 100, + SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, + ); + entry.duration_ms = 4_000; + session.segments.insert(100, entry); + session.advance_media_readiness_generation(); + session.last_rendered_manifest = Some(RenderedManifest { + body: format!( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_for_body}/{}/000100.ts\n", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ), + first_proxy_seq: 100, + last_proxy_seq: 100, + playlist_duration_ms: 4_000, + valid_until_ms: rendered_at_ms.saturating_add(4_000), + render_gap_segments: 0, + rendered_at_ms, + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![100], + }); + record_test_normal_manifest_commit(&mut session, rendered_at_ms); + session.origin_refresh.in_flight = false; + }); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::from_millis(200)) + .requiring_newer_manifest(old_rendered_at_ms), + ) + .await + .expect("fresh manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert!(body.contains("000100.ts")); + assert!(!body.contains("000000.ts")); + assert!(body.contains(&access_lease_id.0)); +} + +#[tokio::test] +async fn hls_cache_pending_transient_manifest_applies_initial_strip_without_mutating_shared_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); + session.mark_authorized_media_access(super::current_time_millis()); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + let stored_before = session.read().await.transient.last_manifest_body.clone().expect("transient manifest body"); + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); + assert_eq!(session.read().await.transient.last_manifest_body.as_ref().expect("stored manifest"), &stored_before); + assert_eq!(media_uri_count(&stored_before), 6); +} + +fn assert_encrypted_transient_terminal_incompatibility(snapshot: HlsLeaseManifestSnapshot, now_ms: u64) { + let asset = terminal_test_asset(); + let bundle_target_duration_ms = snapshot.target_duration_ms.max(asset.duration_ms()); + let base_timing = Some(HlsTerminalTailBuildInput::base_timing_for_test(&asset, &snapshot)); + let base_splice_evidence = Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test(&asset)); + let terminal_splice_evidence = base_splice_evidence.clone(); + assert_eq!( + build_terminal_tail_plan(HlsTerminalTailBuildInput { + generation: HlsTerminalTailGeneration(1), + created_at_ms: now_ms, + base_availability: Arc::from([]), + base_track_signature: Some(asset.track_signature().clone()), + base_splice_evidence, + terminal_splice_evidence, + base_timing, + base_key_bindings: Arc::from([]), + expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable( + HlsTerminalAssetIdentity::from_asset(&asset), + ), + anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test(&asset, bundle_target_duration_ms,), + base_manifest: snapshot, + asset, + }), + Err(HlsTerminalTailCompatibility::TransientPassthroughUnsupported) + ); +} + +#[tokio::test] +async fn encrypted_transient_endpoint_stores_client_visible_key_and_typed_terminal_incompatibility() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let now_ms = super::current_time_millis(); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), now_ms) + .await; + let (proxy_session_id, access_lease_id) = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.clone(); + let access_lease_id = HlsAccessLeaseId("encrypted-access-lease".to_string()); + let body = format!( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:20\n\ + #EXT-X-KEY:METHOD=AES-128,URI=\"/hls/shared/live/{}/{}/r/key.bin\",IV=0x1,KEYFORMAT=\"identity\",KEYFORMATVERSIONS=\"1\"\n\ + #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/20.ts\n\ + #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/21.ts\n\ + #EXTINF:4.0,\n/hls/shared/live/{}/{}/r/22.ts\n", + proxy_session_id.0, + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, + proxy_session_id.0, + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, + proxy_session_id.0, + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, + proxy_session_id.0, + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER, + ); + session.transient.replace_manifest_with_semantics(body, now_ms, Some(12_000)); + session.mark_authorized_media_access(now_ms); + (proxy_session_id, access_lease_id) + }; + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + access_lease_id.clone(), + HlsPlaybackFamilyKey::new("hls-user", "encrypted-client"), + proxy_session_id.clone(), + "hls-user".to_string(), + "encrypted-session".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + 60_000, + )) + .await; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &StripConfig { mode: HlsStripMode::Segments, value: 0 }, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("encrypted transient response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + let lease = app_state + .hls_proxy + .access_lease_response_snapshot(&access_lease_id, &proxy_session_id, now_ms) + .await + .expect("lease snapshot"); + let snapshot = lease.last_manifest_snapshot.expect("manifest snapshot"); + let encryption = snapshot.active_encryption.as_ref().expect("active encryption"); + + assert!(body.contains(&format!("URI=\"/hls/shared/live/{}/{}/r/key.bin\"", proxy_session_id.0, access_lease_id.0))); + assert_eq!(snapshot.delivery_mode, HlsManifestDeliveryMode::TransientPassthrough); + assert_eq!(encryption.method, "AES-128"); + assert_eq!(encryption.iv.as_deref(), Some("0x1")); + assert_eq!(encryption.key_format.as_deref(), Some("identity")); + assert_eq!(encryption.key_format_versions.as_deref(), Some("1")); + assert!(encryption.can_reset_to_clear); + + assert_encrypted_transient_terminal_incompatibility(snapshot, now_ms); +} + +#[tokio::test] +async fn hls_cache_idle_transient_manifest_applies_initial_strip_without_mutating_shared_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); + session.mark_authorized_media_access(super::current_time_millis()); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + let stored_before = session.read().await.transient.last_manifest_body.clone().expect("transient manifest body"); + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Idle, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("idle transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); + assert_eq!(session.read().await.transient.last_manifest_body.as_ref().expect("stored manifest"), &stored_before); + assert_eq!(media_uri_count(&stored_before), 6); +} + +#[tokio::test] +async fn hls_cache_activated_transient_manifest_skips_initial_strip() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); + session.mark_authorized_media_access(super::current_time_millis().saturating_sub(16_000)); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await + .expect("transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 6); + assert!(body.contains(&access_lease_id.0)); + assert!(!body.contains(crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER)); +} + +#[tokio::test] +async fn hls_cache_transient_manifest_without_media_activity_is_not_served_from_committed_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_id), + rendered_at_ms, + Some(60_000), + ); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await; + + assert!(response.is_none()); +} + +#[tokio::test] +async fn hls_cache_no_media_yet_transient_manifest_is_served_for_initial_canonical_response() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + let rendered_at_ms = super::current_time_millis(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_id), + rendered_at_ms, + Some(60_000), + ); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::ZERO), + ) + .await + .expect("initial transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); +} + +#[tokio::test] +async fn hls_cache_transient_manifest_outside_soft_window_is_not_served_from_committed_body() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.0.clone(); + session.transient.replace_manifest_with_semantics(transient_manifest_body(&proxy_session_id), 100, None); + session.mark_authorized_media_access(super::current_time_millis().saturating_sub(60_000)); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Activated, + &strip, + None, + super::HlsCachedManifestOptions::committed_only(Duration::ZERO), + ) + .await; + + assert!(response.is_none()); +} + +#[tokio::test] +async fn hls_cache_expired_transient_manifest_with_active_binding_is_served_while_manifest_valid() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let now_ms = super::current_time_millis(); + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.clone(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_id.0), + now_ms.saturating_sub(1_000), + Some(60_000), + ); + session.mark_authorized_media_access(now_ms.saturating_sub(60_000)); + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::from("test-input"), + Arc::from("test-account"), + &proxy_session_id, + now_ms, + )); + session.origin_refresh.in_flight = true; + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), + ) + .await + .expect("valid committed transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); +} + +#[tokio::test] +async fn hls_cache_expired_transient_manifest_with_active_binding_is_not_served_after_manifest_validity() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let now_ms = super::current_time_millis(); + let _proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + let proxy_session_id = session.proxy_session_id.clone(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_id.0), + now_ms.saturating_sub(60_000), + Some(1_000), + ); + session.mark_authorized_media_access(now_ms.saturating_sub(60_000)); + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::from("test-input"), + Arc::from("test-account"), + &proxy_session_id, + now_ms, + )); + proxy_session_id + }; + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::ZERO), + ) + .await; + + assert!(response.is_none()); +} + +#[tokio::test] +async fn hls_cache_no_media_yet_waits_for_first_normal_manifest_commit() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = { + let mut session = session.write().await; + session.origin_refresh.in_flight = true; + session.proxy_session_id.clone() + }; + let session_for_commit = Arc::clone(&session); + let proxy_session_for_body = proxy_session_id.0.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let mut session = session_for_commit.write().await; + let rendered_at_ms = super::current_time_millis(); + let mut entry = test_segment_entry( + &session.proxy_session_id, + 100, + SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: rendered_at_ms }, + ); + entry.duration_ms = 4_000; + session.segments.insert(100, entry); + session.advance_media_readiness_generation(); + session.last_rendered_manifest = Some(RenderedManifest { + body: format!( + "#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:100\n#EXTINF:4.0,\n/hls/shared/live/{proxy_session_for_body}/{}/000100.ts\n", + crate::api::model::HLS_ACCESS_LEASE_ID_PLACEHOLDER + ), + first_proxy_seq: 100, + last_proxy_seq: 100, + playlist_duration_ms: 4_000, + valid_until_ms: rendered_at_ms.saturating_add(4_000), + render_gap_segments: 0, + rendered_at_ms, + discontinuity_sequence: 0, + target_duration_ms: 4_000, + segment_proxy_seqs: vec![100], + }); + record_test_normal_manifest_commit(&mut session, rendered_at_ms); + session.origin_refresh.in_flight = false; + }); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 0 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), + ) + .await + .expect("initial normal manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert!(body.contains(&access_lease_id.0)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); +} + +#[tokio::test] +async fn hls_cache_no_media_yet_waits_for_first_transient_manifest_commit() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + session.origin_refresh.in_flight = true; + session.proxy_session_id.clone() + }; + let session_for_commit = Arc::clone(&session); + let proxy_session_for_body = proxy_session_id.0.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let mut session = session_for_commit.write().await; + let rendered_at_ms = super::current_time_millis(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_for_body), + rendered_at_ms, + Some(60_000), + ); + session.origin_refresh.in_flight = false; + }); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), + ) + .await + .expect("initial transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); +} + +#[tokio::test] +async fn hls_cache_expired_transient_manifest_waits_for_revalidation_commit() { + let app_state = test_app_state(); + enable_hls_cache(&app_state); + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + session.mark_authorized_media_access(super::current_time_millis().saturating_sub(60_000)); + session.origin_refresh.in_flight = true; + session.proxy_session_id.clone() + }; + let session_for_commit = Arc::clone(&session); + let proxy_session_for_body = proxy_session_id.0.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let mut session = session_for_commit.write().await; + let rendered_at_ms = super::current_time_millis(); + session.transient.replace_manifest_with_semantics( + transient_manifest_body(&proxy_session_for_body), + rendered_at_ms, + Some(60_000), + ); + session.origin_refresh.in_flight = false; + }); + let access_lease_id = HlsAccessLeaseId("access-lease".to_string()); + let strip = StripConfig { mode: HlsStripMode::Segments, value: 3 }; + + let response = try_test_hls_cached_manifest_response( + &app_state, + &session, + &access_lease_id, + HlsAccessLeaseState::Pending, + &strip, + None, + super::HlsCachedManifestOptions::initial(Duration::from_millis(200)), + ) + .await + .expect("revalidated transient manifest response"); + let body = String::from_utf8(response_body(response).await.to_vec()).expect("manifest utf8"); + + assert_eq!(media_uri_count(&body), 3); + assert!(body.contains(&access_lease_id.0)); + assert!(session.read().await.activity.last_authorized_media_at_ms.is_some()); +} + +async fn grant_hls_proxy_lease(app_state: &Arc, proxy_session_id: &str) -> String { + create_active_hls_user_session(app_state).await; + let now_ms = super::current_time_millis(); + let lease_id = HlsAccessLeaseId(format!("test-access-lease-{proxy_session_id}")); + let family_key = HlsPlaybackFamilyKey::new("hls-user", test_fingerprint().key); + app_state + .hls_proxy + .prepare_access_lease(HlsAccessLease::pending( + lease_id.clone(), + family_key, + ProxySessionId(proxy_session_id.to_string()), + "hls-user".to_string(), + "hls-session-token".to_string(), + 1, + "12345".to_string(), + 12345, + now_ms, + super::hls_access_lease_ttl_ms(app_state), + )) + .await; + assert!(app_state + .hls_proxy + .activate_access_lease( + &lease_id, + &ProxySessionId(proxy_session_id.to_string()), + now_ms, + HlsAccessLeaseTiming { + active_window_ms: 5_000, + valid_window_ms: super::hls_access_lease_ttl_ms(app_state), + }, + ) + .await + .is_activated()); + lease_id.0 +} + +async fn hls_proxy_uri(app_state: &Arc, proxy_session_id: &str, suffix: &str) -> String { + let access_lease_id = grant_hls_proxy_lease(app_state, proxy_session_id).await; + let uri = format!("/hls/shared/live/{proxy_session_id}/{access_lease_id}/{suffix}"); + if suffix.starts_with("r/") { + publish_test_transient_resource_membership(app_state, proxy_session_id, &access_lease_id, &uri).await; + } + uri +} + +async fn publish_test_transient_resource_membership( + app_state: &Arc, + proxy_session_id: &str, + access_lease_id: &str, + resource_uri: &str, +) { + let proxy_session_id = ProxySessionId(proxy_session_id.to_string()); + let access_lease_id = HlsAccessLeaseId(access_lease_id.to_string()); + let now_ms = super::current_time_millis(); + let publication = app_state + .hls_proxy + .prepare_access_lease_manifest_publication(&access_lease_id, &proxy_session_id, now_ms) + .await + .expect("test resource lease accepts manifest publication"); + let snapshot = HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::TransientPassthrough, + source_commit_identity: HlsManifestCommitIdentity::new(now_ms), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 0, + delivered_at_ms: now_ms, + first_proxy_seq: 0, + last_proxy_seq: 0, + visible_segments: Arc::from([]), + discontinuity_sequence: 0, + target_duration_ms: 4_000, + playlist_duration_ms: 0, + last_visible_media_end_ms: 0, + active_map: None, + active_encryption: None, + container: HlsMediaContainer::MpegTs, + }; + let published_resource_ids = HlsPublishedTransientResourceIds::from_manifest_body(resource_uri); + assert!(app_state + .hls_proxy + .commit_access_lease_manifest_publication_with_resources( + &access_lease_id, + &proxy_session_id, + publication, + snapshot, + published_resource_ids, + now_ms, + ) + .await + .is_committed()); +} + +fn regression_origin_manifest(first_sequence: u64, segment_count: usize) -> Vec { + let mut manifest = + format!("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:{first_sequence}\n"); + for offset in 0..segment_count { + let sequence = first_sequence.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX)); + let _ = writeln!(&mut manifest, "#EXTINF:12.0,\n{sequence}.ts"); + } + manifest.into_bytes() +} + +fn regression_origin_refresh_request( + app_state: &Arc, + session: HlsSessionHandle, + manifest_url: &str, + access_lease_id: Option, +) -> OriginRefreshRequest { + OriginRefreshRequest { + app_config: Arc::clone(&app_state.app_config), + session, + origin_entry: LiveHlsOriginEntry::parse(manifest_url).expect("regression origin entry"), + headers: HeaderMap::new(), + origin_provider_session_headers: HeaderMap::new(), + client: app_state.http_client.load().as_ref().clone(), + no_redirect_client: app_state.http_client_no_redirect.load().as_ref().clone(), + use_manual_redirects: false, + segment_cache: Arc::clone(app_state.hls_proxy.segment_cache()), + hls_proxy: Arc::clone(&app_state.hls_proxy), + segment_repair: Arc::clone(app_state.hls_proxy.segment_repair()), + segment_worker_pool: Arc::clone(app_state.hls_proxy.segment_worker_pool()), + map_worker_pool: Arc::clone(app_state.hls_proxy.map_worker_pool()), + origin_manifest_timeout_ms: app_state.hls_proxy.origin_manifest_timeout_ms(), + manifest_recovery_burst: app_state.hls_proxy.manifest_recovery_burst(), + strip: app_state.hls_proxy.strip(), + retry_policy: RetryPolicy { delays_ms: [0; 5], jitter_max_ms: 0 }, + reverse_proxy_rewrite_secret: app_state.get_encrypt_secret().to_vec(), + transient_resource_ttl_ms: app_state.hls_proxy.transient_resource_ttl_ms(), + manifest_commit_requirement: HlsManifestCommitRequirement::CommittedManifestAllowed, + fresh_manifest_requirement_generation: None, + acceptance_directive: HlsManifestAcceptanceDirective::none(), + access_lease_id, + disabled_headers: None, + now_ms: super::current_time_millis(), + origin_io: None, + post_refresh_runtime: None, + } +} + +async fn wait_for_ready_timeline(session: &HlsSessionHandle, expected_ready: usize) { + let wait = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let ready = session + .read() + .await + .segments + .values() + .filter(|segment| matches!(segment.status, SegmentCacheStatus::Ready { .. })) + .count(); + if ready >= expected_ready { + break; + } + tokio::task::yield_now().await; + } + }) + .await; + if wait.is_err() { + let session = session.read().await; + let statuses = + session.segments.values().map(|segment| (segment.proxy_seq, segment.status.clone())).collect::>(); + panic!("READY timeline deadline: expected={expected_ready} statuses={statuses:?}"); + } +} + +async fn extend_ready_segment_as_sparse_file( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_seq: u64, + logical_size: u64, +) { + let cache_key = session.read().await.segments.get(&proxy_seq).expect("mapped sparse segment").cache_key.clone(); + let metadata = app_state + .hls_proxy + .segment_cache() + .metadata(&cache_key) + .await + .expect("sparse cache metadata read") + .expect("READY sparse cache object"); + let file = tokio::fs::OpenOptions::new().write(true).open(&metadata.path).await.expect("sparse cache object opens"); + file.set_len(logical_size).await.expect("sparse cache object extends"); + let mut session = session.write().await; + let segment = session.segments.get_mut(&proxy_seq).expect("sparse segment remains mapped"); + segment.status = + SegmentCacheStatus::Ready { content_length: logical_size, ready_at_ms: super::current_time_millis() }; + session.advance_media_readiness_generation(); + session.render_and_store_manifest(super::current_time_millis()).expect("sparse timeline renders"); +} + +fn terminal_test_asset() -> Arc { + let bytes = bytes::Bytes::from_static(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/hls/channel_unavailable.ts" + ))); + let buffer = TransportStreamBuffer::new(bytes.to_vec()); + snapshot_terminal_media_asset(&buffer).expect("terminal test asset is valid") +} + +async fn publish_test_manifest_and_exhaust_configured_acceptance( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + lease_id: &HlsAccessLeaseId, + snapshot: HlsLeaseManifestSnapshot, + now_ms: u64, +) { + let target_duration_ms = snapshot.target_duration_ms; + let publication_guard = app_state + .hls_proxy + .prepare_access_lease_manifest_publication(lease_id, proxy_session_id, now_ms) + .await + .expect("live lease accepts publication preparation"); + assert!(app_state + .hls_proxy + .commit_access_lease_manifest_publication(lease_id, proxy_session_id, publication_guard, snapshot, now_ms,) + .await + .is_committed()); + + let terminal_response = app_state.app_config.custom_stream_response.load_full(); + let terminal_asset = terminal_response + .as_ref() + .and_then(|responses| responses.channel_unavailable.as_ref()) + .and_then(|buffer| snapshot_terminal_media_asset(buffer).ok()) + .expect("configured terminal test asset"); + let terminal_key = + prepared_terminal_bundle_key(&terminal_asset, target_duration_ms, HLS_TERMINAL_TAIL_SEGMENT_COUNT); + let state = app_state.hls_proxy.start_prepared_terminal_bundle( + terminal_asset, + target_duration_ms, + HLS_TERMINAL_TAIL_SEGMENT_COUNT, + ); + let state = match state { + HlsPreparedTerminalBundleState::Preparing { .. } => app_state + .hls_proxy + .wait_for_prepared_terminal_bundle(terminal_key) + .await + .expect("terminal bundle completion"), + state => state, + }; + assert!(matches!( + state, + HlsPreparedTerminalBundleState::Ready { ref bundle } if bundle.key == terminal_key + )); + + let session = + app_state.hls_proxy.sessions().get_by_proxy_session_id(proxy_session_id).await.expect("warm shared session"); + let mut session = session.write().await; + session.origin_control.record_media_progress(now_ms, target_duration_ms); + let burst_plan = app_state.hls_proxy.manifest_recovery_burst().level.plan(); + let operation_timeout = HlsOperationTimeoutMs::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms()); + let expected_eta = HlsRecoveryEtaMs::from_millis(app_state.hls_proxy.origin_manifest_timeout_ms()); + let timing = HlsAcceptanceEpisodeTiming::from_input(&HlsAcceptanceEpisodeTimingInput { + started_at_ms: now_ms, + burst_plan, + target_duration_ms, + transition_margin: HlsTransitionMarginMs::from_millis(target_duration_ms), + workload: HlsRecoveryWorkload::clear_fetch(), + observed_latency: HlsObservedRecoveryLatency::default(), + required_terminal_media_key: Some(terminal_key), + terminal_media_preparation: HlsTerminalMediaPreparationState::Ready { key: terminal_key }, + policy: HlsRecoveryTimingPolicy::new(operation_timeout, operation_timeout, expected_eta, expected_eta), + }); + session.origin_control.begin_acceptance_episode( + now_ms, + burst_plan, + HlsManifestAcceptanceTrigger::RecoveryRequired, + &timing, + ); + session.origin_control.path_condition = HlsOriginPathCondition::HardFetchFailure; + let episode = session.origin_control.acceptance_episode.as_mut().expect("acceptance episode"); + episode.record_full_burst(); + episode.record_exhaustion(HlsManifestAcceptanceExhaustionReason::AllFailed); + episode.hold_after_uncommitted_burst(None, None); +} + +async fn terminalize_existing_test_lease( + app_state: &Arc, + proxy_session_id: &str, + lease_id: &str, + base_proxy_seq: u64, +) -> TransportStreamBuffer { + let proxy_session_id = ProxySessionId(proxy_session_id.to_string()); + let lease_id = HlsAccessLeaseId(lease_id.to_string()); + let buffer = TransportStreamBuffer::new( + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")).to_vec(), + ); + let asset = snapshot_terminal_media_asset(&buffer).expect("terminal test asset is valid"); + let base_manifest = HlsLeaseManifestSnapshot { + delivery_mode: HlsManifestDeliveryMode::NormalCacheTimeline, + source_commit_identity: HlsManifestCommitIdentity::new(7), + uri_materialization: None, + finalized_transient_manifest_generation: None, + snapshot_generation: 7, + delivered_at_ms: super::current_time_millis(), + first_proxy_seq: base_proxy_seq, + last_proxy_seq: base_proxy_seq, + visible_segments: Arc::from([HlsLeaseManifestSegment { + proxy_seq: base_proxy_seq, + duration_ms: 4_000, + uri: format!("/iptv/hls/shared/live/{}/{}/{base_proxy_seq:06}.ts", proxy_session_id.0, lease_id.0).into(), + discontinuity_before: false, + map_ref_ready: true, + encryption: None, + }]), + discontinuity_sequence: 3, + target_duration_ms: asset.duration_ms().saturating_add(1_000), + playlist_duration_ms: 4_000, + last_visible_media_end_ms: 4_000, + active_map: None, + active_encryption: None, + container: HlsMediaContainer::MpegTs, + }; + let base_timing = Some(HlsTerminalTailBuildInput::base_timing_for_test(&asset, &base_manifest)); + let base_splice_evidence = Some(HlsTerminalTailBuildInput::compatible_splice_evidence_for_test(&asset)); + let terminal_splice_evidence = base_splice_evidence.clone(); + let plan = build_terminal_tail_plan(HlsTerminalTailBuildInput { + generation: HlsTerminalTailGeneration(17), + created_at_ms: super::current_time_millis(), + base_availability: Arc::from([HlsTerminalBaseSegmentAvailability { + proxy_seq: base_proxy_seq, + media_state: HlsTerminalBaseMediaState::Ready, + required_map_ready: true, + required_key_ready: true, + protection: HlsTerminalBaseProtection::Protectable, + }]), + base_track_signature: Some(asset.track_signature().clone()), + base_splice_evidence, + terminal_splice_evidence, + base_timing, + base_key_bindings: Arc::from([]), + expected_asset: HlsRuntimeCustomTailAssetIdentity::channel_unavailable(HlsTerminalAssetIdentity::from_asset( + &asset, + )), + base_manifest: base_manifest.clone(), + anchored_bundle: HlsTerminalTailBuildInput::anchored_bundle_for_test(&asset, base_manifest.target_duration_ms), + asset, + }) + .expect("terminal test plan is compatible"); + let protection = HlsTerminalTailProtection { + generation: plan.generation, + base_proxy_seqs: Arc::clone(&plan.protected_base_proxy_seqs), + key_bindings: plan.key_bindings(), + }; + { + let mut leases = app_state.hls_proxy.access_leases().write().await; + let mut lease = leases.remove_access_lease(&lease_id).expect("test lease exists before terminal cutover"); + lease.last_manifest_snapshot = Some(base_manifest); + lease.playback_mode = HlsLeasePlaybackMode::TerminalTail(Arc::new(plan)); + leases.prepare_access_lease(lease); + } + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id) + .await + .expect("test session exists before terminal cutover"); + session.write().await.install_terminal_tail_protection(lease_id, protection); + buffer +} + +async fn terminal_test_plan_shape(app_state: &Arc, proxy_session_id: &str, lease_id: &str) -> (u64, u16) { + let snapshot = app_state + .hls_proxy + .access_lease_response_snapshot( + &HlsAccessLeaseId(lease_id.to_string()), + &ProxySessionId(proxy_session_id.to_string()), + super::current_time_millis(), + ) + .await + .expect("terminal test lease snapshot exists"); + let HlsLeasePlaybackMode::TerminalTail(plan) = snapshot.playback_mode else { + panic!("terminal test lease keeps terminal playback mode"); + }; + (plan.generation.0, plan.segment_count) +} + +async fn wait_for_provider_connection_count(app_state: &Arc, expected: usize) { + for _ in 0..50 { + let actual = app_state.active_provider.get_provider_connections_count().await; + if actual == expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(app_state.active_provider.get_provider_connections_count().await, expected); +} + +fn normal_manifest(body: &str) -> crate::processing::parser::hls::origin_manifest::ParsedOriginManifest { + match parse_origin_media_manifest(body, "http://origin.example.com/live/final/index.m3u8") { + OriginManifestParseOutcome::Normal(manifest) => manifest, + OriginManifestParseOutcome::TransientPassthrough { reason } => { + panic!("expected normal manifest: {reason:?}") + } + } +} + +async fn map_segment(app_state: &Arc, proxy_seq: u64, extension: &str) -> String { + map_segment_with_origin_url(app_state, proxy_seq, extension, &format!("{proxy_seq}.{extension}")).await +} + +async fn map_segment_with_origin_url( + app_state: &Arc, + proxy_seq: u64, + _extension: &str, + origin_url: &str, +) -> String { + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let manifest = + normal_manifest(&format!("#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:{proxy_seq}\n#EXTINF:4.0,\n{origin_url}\n")); + let mut session = session.write().await; + session.proxy_next_seq = Some(proxy_seq); + session.apply_origin_manifest(&manifest).expect("manifest should map"); + session.proxy_session_id.0.clone() +} + +async fn map_ready_segment(app_state: &Arc, proxy_seq: u64, extension: &str, body: &[u8]) -> String { + let proxy_session_id = map_ready_segment_without_lease(app_state, proxy_seq, extension, body).await; + grant_hls_proxy_lease(app_state, &proxy_session_id).await; + proxy_session_id +} + +async fn map_ready_segment_without_lease( + app_state: &Arc, + proxy_seq: u64, + extension: &str, + body: &[u8], +) -> String { + let proxy_session_id = map_segment(app_state, proxy_seq, extension).await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + let cache_key = { + let session = session.read().await; + session.segments.get(&proxy_seq).expect("segment should be mapped").cache_key.clone() + }; + let metadata = app_state + .hls_proxy + .segment_cache() + .write_bytes_and_commit(&cache_key, body) + .await + .expect("cache commit should succeed"); + { + let mut session = session.write().await; + session.segments.get_mut(&proxy_seq).expect("segment should be mapped").status = + SegmentCacheStatus::Ready { content_length: metadata.size, ready_at_ms: 200 }; + } + proxy_session_id +} + +async fn map_hls_map(app_state: &Arc, body: &[u8], grant_lease: bool) -> String { + let session = app_state + .hls_proxy + .get_or_create_session(HlsSessionKey::new(1, "12345"), &app_state.get_encrypt_secret(), 100) + .await; + let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n000123.m4s\n"); + let proxy_session_id = { + let mut session = session.write().await; + session.apply_origin_manifest(&manifest).expect("manifest should map"); + session.proxy_session_id.0.clone() + }; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + let cache_key = { + let session = session.read().await; + session.maps.get(&ProxyMapId(0)).expect("map should be mapped").cache_key.clone() + }; + let metadata = app_state + .hls_proxy + .segment_cache() + .write_bytes_and_commit(&cache_key, body) + .await + .expect("map cache commit should succeed"); + { + let mut session = session.write().await; + session.maps.get_mut(&ProxyMapId(0)).expect("map should be mapped").status = + MapCacheStatus::Ready { content_length: metadata.size, ready_at_ms: 200 }; + } + if grant_lease { + grant_hls_proxy_lease(app_state, &proxy_session_id).await; + } + proxy_session_id +} + +async fn map_transient_resource( + app_state: &Arc, + origin_url: &str, + extension: &str, + grant_lease: bool, +) -> (String, String) { + map_transient_resource_with_kind(app_state, origin_url, extension, grant_lease, TransientResourceKind::Segment) + .await +} + +async fn map_transient_resource_with_kind( + app_state: &Arc, + origin_url: &str, + extension: &str, + grant_lease: bool, + kind: TransientResourceKind, +) -> (String, String) { + let secret = b"rewrite-secret"; + let now_ms = super::current_time_millis(); + let session = app_state.hls_proxy.get_or_create_session(HlsSessionKey::new(1, "12345"), secret, now_ms).await; + let resource_id = build_transient_resource_id(origin_url, secret); + let proxy_session_id = { + let mut session = session.write().await; + session.mode = + HlsSessionMode::TransientPassthrough { reason: crate::api::model::TransientPassthroughReason::ExtXKey }; + session.transient.upsert_resources([TransientResourceRef::new( + kind, + origin_url, + secret, + now_ms, + 300_000, + Some(extension.to_string()), + )]); + session.proxy_session_id.0.clone() + }; + if grant_lease { + grant_hls_proxy_lease(app_state, &proxy_session_id).await; + } + (proxy_session_id, resource_id.0) +} + +async fn get_response(app_state: Arc, uri: &str, range: Option<&str>) -> Response { + request_response(app_state, Method::GET, uri, range).await +} + +async fn request_response(app_state: Arc, method: Method, uri: &str, range: Option<&str>) -> Response { + let router = hls_api_register().with_state(app_state); + let mut request = Request::builder().method(method).uri(uri); + if let Some(range) = range { + request = request.header(header::RANGE, range); + } + let mut request = request.body(Body::empty()).expect("request should build"); + request.extensions_mut().insert(ConnectInfo(test_addr())); + router.oneshot(request).await.expect("response") +} + +async fn get_status(app_state: Arc, uri: &str) -> StatusCode { + get_response(app_state, uri, None).await.status() +} + +async fn hls_session_last_media_at_ms(app_state: &Arc, proxy_session_id: &str) -> Option { + app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.to_string())) + .await + .expect("session should exist") + .read() + .await + .activity + .last_authorized_media_at_ms +} + +async fn assert_no_hls_cache_stream_registered(app_state: &Arc) { + assert!(app_state.active_users.active_streams().await.is_empty()); +} + +async fn response_body(response: Response) -> bytes::Bytes { + response.into_body().collect().await.expect("body should collect").to_bytes() +} + +async fn single_variant_master_playlist(response: Response) -> (u32, String) { + let body = response_body(response).await; + let body = std::str::from_utf8(&body).expect("master playlist should be UTF-8"); + let mut lines = body.lines(); + assert_eq!(lines.next(), Some("#EXTM3U")); + let bandwidth = lines + .next() + .and_then(|line| line.strip_prefix("#EXT-X-STREAM-INF:BANDWIDTH=")) + .and_then(|value| value.parse::().ok()) + .expect("positive master playlist bandwidth"); + let uri = lines.next().expect("single variant URI").to_string(); + assert!(lines.next().is_none(), "master playlist must contain exactly one variant"); + (bandwidth, uri) +} + +async fn single_variant_uri(response: Response) -> String { single_variant_master_playlist(response).await.1 } + +fn access_lease_id_from_variant_uri(uri: &str) -> &str { + uri.trim_end_matches("/manifest.m3u8").rsplit('/').next().expect("access lease id in variant URI") +} + +fn proxy_session_id_from_variant_uri(uri: &str) -> &str { + let mut parts = uri.trim_end_matches("/manifest.m3u8").rsplit('/'); + let _access_lease_id = parts.next().expect("access lease id in variant URI"); + parts.next().expect("proxy session id in variant URI") +} + +fn manifest_media_sequence(body: &str) -> u64 { + body.lines() + .find_map(|line| line.strip_prefix("#EXT-X-MEDIA-SEQUENCE:")) + .and_then(|value| value.parse().ok()) + .expect("media sequence") +} + +async fn access_lease_session_token( + app_state: &Arc, + proxy_session_id: &ProxySessionId, + access_lease_id: &HlsAccessLeaseId, +) -> String { + app_state + .hls_proxy + .access_lease(access_lease_id, proxy_session_id, super::current_time_millis()) + .await + .expect("access lease should exist") + .user_session_token +} + +#[test] +fn transient_full_object_cacheable_request_accepts_open_zero_range() { + use crate::api::model::is_hls_transient_full_object_cacheable_request; + + assert!(is_hls_transient_full_object_cacheable_request(None)); + assert!(is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=0-")))); + assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=4-")))); + assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=-4")))); + assert!(!is_hls_transient_full_object_cacheable_request(Some(&HeaderValue::from_static("bytes=0-1,4-5")))); +} + +async fn assert_hls_cache_stream_registered(app_state: &Arc, proxy_session_id: &str) { + let streams = app_state.active_users.active_streams().await; + assert_eq!(streams.len(), 1); + let stream = &streams[0]; + assert_eq!(stream.username, "hls-user"); + assert_eq!(stream.session_token.as_deref(), Some("hls-session-token")); + assert_eq!(stream.provider.as_ref(), "origin-provider"); + assert_eq!(stream.channel.item_type, PlaylistItemType::LiveHls); + assert!(stream.channel.shared); + assert_eq!( + stream.channel.shared_stream_id, + Some(super::hls_cache_shared_stream_id(&ProxySessionId(proxy_session_id.to_string()))) + ); + assert_eq!(stream.channel.shared_joined_existing, Some(false)); + assert_eq!(stream.channel.url.as_ref(), format!("/hls/shared/live/{proxy_session_id}/manifest.m3u8")); + assert!(!stream.channel.url.contains("test-access-lease")); + assert!(!stream.channel.url.contains("hls-session-token")); + assert!(!stream.channel.url.contains("origin.example.com")); + assert!(!stream.channel.url.contains("/hls/hls-user/")); +} + +fn stats_provider_test_user_session(provider: &str) -> UserSession { + UserSession { + token: "stats-session-token".to_string(), + transition_version: 0, + virtual_id: 12345, + provider: Arc::from(provider), + stream_url: Arc::from("http://origin.example.com/live/12345.m3u8"), + provider_session_headers: HashMap::new(), + addr: test_addr(), + socket_bound: false, + active_addrs: Vec::new(), + ts: 100, + started_at: 100, + permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + lifecycle: PlaybackLifecycle::Active, + } +} + +#[test] +fn hls_cache_stats_provider_prefers_active_origin_account_binding() { + let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); + let proxy_session_id = ProxySessionId("stats-session".to_string()); + let binding = HlsOriginAccountBinding::new( + Arc::clone(&origin_source.input_name), + Arc::from("cdn-dev-alias"), + &proxy_session_id, + 100, + ); + let user_session = stats_provider_test_user_session("cdn-dev"); + + let provider = super::hls_cache_stats_provider(&origin_source, Some(&binding), &user_session); + + assert_eq!(provider.as_ref(), "cdn-dev-alias"); +} + +#[test] +fn hls_cache_stats_provider_falls_back_when_origin_account_binding_is_not_active() { + let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); + let proxy_session_id = ProxySessionId("stats-session".to_string()); + let mut binding = HlsOriginAccountBinding::new( + Arc::clone(&origin_source.input_name), + Arc::from("cdn-dev-alias"), + &proxy_session_id, + 100, + ); + binding.detach(HlsOriginAccountDetachedReason::Cleanup, 200); + let user_session = stats_provider_test_user_session("session-provider"); + + let provider = super::hls_cache_stats_provider(&origin_source, Some(&binding), &user_session); + + assert_eq!(provider.as_ref(), "session-provider"); +} + +#[test] +fn hls_cache_stats_provider_falls_back_to_input_name_without_session_provider() { + let origin_source = HlsOriginSource::new(1, Arc::from("cdn-dev"), "12345", HlsOriginSourceKind::XtreamLive); + let user_session = stats_provider_test_user_session(""); + + let provider = super::hls_cache_stats_provider(&origin_source, None, &user_session); + + assert_eq!(provider.as_ref(), "cdn-dev"); +} + +async fn register_hls_cache_stream_for_stats_test( + app_state: &Arc, + session: &HlsSessionHandle, + proxy_session_id: &ProxySessionId, + session_token: &str, + fingerprint: &Fingerprint, + lease_id: &str, +) { + create_active_hls_user_session_with( + app_state, + session_token, + "origin-provider", + "http://origin.example.com/live/12345.m3u8", + fingerprint.addr, + ) + .await; + let context = test_hls_access_context_with( + proxy_session_id.clone(), + HlsAccessLeaseId(lease_id.to_string()), + session_token, + fingerprint.key.clone(), + ); + super::ensure_hls_cache_stream_registered(app_state, fingerprint, &HeaderMap::new(), &context, session) + .await + .expect("HLS stream registers"); +} + +fn find_stream_by_session_token( + streams: &[shared::model::StreamInfo], + session_token: &str, +) -> shared::model::StreamInfo { + streams + .iter() + .find(|stream| stream.session_token.as_deref() == Some(session_token)) + .unwrap_or_else(|| panic!("{session_token} stream should exist")) + .clone() +} + +#[tokio::test] +async fn hls_cache_stream_stats_mark_additional_viewers_as_joined_existing() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; + let proxy_session_id = ProxySessionId(proxy_session_id); + let shared_stream_id = super::hls_cache_shared_stream_id(&proxy_session_id); + let session = + app_state.hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.expect("session should exist"); + let first_fingerprint = test_fingerprint(); + let second_fingerprint = test_fingerprint_with_addr(test_addr_with_port(55124)); + register_hls_cache_stream_for_stats_test( + &app_state, + &session, + &proxy_session_id, + "hls-session-token", + &first_fingerprint, + "first-access-lease", + ) + .await; + + let streams = app_state.active_users.active_streams().await; + let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); + let first_meter_uid = first_stream.meter_uid; + assert!(first_stream.channel.shared); + assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); + + register_hls_cache_stream_for_stats_test( + &app_state, + &session, + &proxy_session_id, + "hls-second-session-token", + &second_fingerprint, + "second-access-lease", + ) + .await; + + let streams = app_state.active_users.active_streams().await; + let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); + let second_stream = find_stream_by_session_token(&streams, "hls-second-session-token"); + assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); + assert_eq!(second_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(second_stream.channel.shared_joined_existing, Some(true)); + + register_hls_cache_stream_for_stats_test( + &app_state, + &session, + &proxy_session_id, + "hls-session-token", + &first_fingerprint, + "first-access-lease", + ) + .await; + + let streams = app_state.active_users.active_streams().await; + let first_stream = find_stream_by_session_token(&streams, "hls-session-token"); + assert_eq!(first_stream.channel.shared_stream_id, Some(shared_stream_id)); + assert_eq!(first_stream.channel.shared_joined_existing, Some(false)); + assert_eq!(first_stream.meter_uid, first_meter_uid); +} + +struct TestSegmentOrigin { + base_url: String, + key_requests: Arc, + manifest_requests: Arc, + segment_requests: Arc, + key_bytes: Option>>>, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for TestSegmentOrigin { + fn drop(&mut self) { self.task.abort(); } +} + +impl TestSegmentOrigin { + fn key_request_count(&self) -> usize { self.key_requests.load(Ordering::SeqCst) } + + fn manifest_request_count(&self) -> usize { self.manifest_requests.load(Ordering::SeqCst) } + + fn segment_request_count(&self) -> usize { self.segment_requests.load(Ordering::SeqCst) } + + async fn set_key_bytes(&self, bytes: Arc<[u8]>) { + if let Some(key_bytes) = &self.key_bytes { + *key_bytes.write().await = bytes; + } + } +} + +async fn spawn_test_segment_origin(body: &'static [u8]) -> TestSegmentOrigin { + spawn_test_status_origin(StatusCode::OK, body).await +} + +async fn spawn_test_encrypted_hls_origin( + manifest: &'static [u8], + key_bytes: Arc<[u8]>, + plaintext_segment: Arc<[u8]>, +) -> TestSegmentOrigin { + let manifest = Arc::<[u8]>::from(manifest); + let key_bytes = Arc::new(RwLock::new(key_bytes)); + let key_bytes_for_task = Arc::clone(&key_bytes); + let key_requests = Arc::new(AtomicUsize::new(0)); + let key_requests_for_task = Arc::clone(&key_requests); + let manifest_requests = Arc::new(AtomicUsize::new(0)); + let manifest_requests_for_task = Arc::clone(&manifest_requests); + let segment_requests = Arc::new(AtomicUsize::new(0)); + let segment_requests_for_task = Arc::clone(&segment_requests); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); + let addr = listener.local_addr().expect("local addr"); + let task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let manifest = Arc::clone(&manifest); + let key_bytes = Arc::clone(&key_bytes_for_task); + let key_requests = Arc::clone(&key_requests_for_task); + let manifest_requests = Arc::clone(&manifest_requests_for_task); + let segment_requests = Arc::clone(&segment_requests_for_task); + let plaintext_segment = Arc::clone(&plaintext_segment); + tokio::spawn(async move { + let mut request = vec![0_u8; 2048]; + let Ok(read) = socket.read(&mut request).await else { + return; + }; + if read == 0 { + return; + } + let path = String::from_utf8_lossy(&request[..read]) + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .map_or_else(|| "/".to_string(), str::to_owned); + let current_key_bytes = Arc::clone(&*key_bytes.read().await); + let body = if path_has_extension(&path, "m3u8") { + manifest_requests.fetch_add(1, Ordering::SeqCst); + manifest + } else if path.ends_with("key.bin") { + key_requests.fetch_add(1, Ordering::SeqCst); + current_key_bytes + } else if let Some(origin_sequence) = path + .rsplit('/') + .next() + .and_then(|file| file.strip_suffix(".ts")) + .and_then(|value| value.parse::().ok()) + { + segment_requests.fetch_add(1, Ordering::SeqCst); + Arc::from(encrypt_test_aes128_cbc_pkcs7( + &plaintext_segment, + ¤t_key_bytes, + test_hls_sequence_iv(origin_sequence), + )) + } else { + Arc::<[u8]>::from([]) + }; + let response = + format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + if socket.write_all(response.as_bytes()).await.is_ok() { + let _ = socket.write_all(&body).await; + } + }); + } + }); + TestSegmentOrigin { + base_url: format!("http://{addr}"), + key_requests, + manifest_requests, + segment_requests, + key_bytes: Some(key_bytes), + task, + } +} + +fn encrypt_test_aes128_cbc_pkcs7(plaintext: &[u8], key: &[u8], iv: [u8; 16]) -> Vec { + let padding_len = 16 - (plaintext.len() % 16); + let mut ciphertext = plaintext.to_vec(); + ciphertext.resize( + plaintext.len().saturating_add(padding_len), + u8::try_from(padding_len).expect("PKCS#7 AES-128 padding fits in u8"), + ); + let cipher = Aes128::new_from_slice(key).expect("test key has AES-128 length"); + let mut previous = iv; + for block in ciphertext.as_chunks_mut::<16>().0 { + for (byte, previous) in block.iter_mut().zip(previous) { + *byte ^= previous; + } + let mut encrypted = Block::::default(); + encrypted.copy_from_slice(block); + cipher.encrypt_block(&mut encrypted); + block.copy_from_slice(&encrypted); + previous.copy_from_slice(block); + } + ciphertext +} + +fn test_hls_sequence_iv(sequence: u64) -> [u8; 16] { + let mut iv = [0_u8; 16]; + iv[8..].copy_from_slice(&sequence.to_be_bytes()); + iv +} + +struct TestBinaryOriginResponse { + status: StatusCode, + location: Option, + body: Arc<[u8]>, +} + +impl TestBinaryOriginResponse { + fn new(status: StatusCode, body: Arc<[u8]>) -> Self { Self { status, location: None, body } } + + fn redirect(location: String) -> Self { + Self { status: StatusCode::FOUND, location: Some(location), body: Arc::from(&b""[..]) } + } +} + +type TestBinaryOriginHandler = Arc TestBinaryOriginResponse + Send + Sync>; + +async fn spawn_test_binary_origin(handler: TestBinaryOriginHandler) -> TestSegmentOrigin { + let key_requests = Arc::new(AtomicUsize::new(0)); + let manifest_requests = Arc::new(AtomicUsize::new(0)); + let segment_requests = Arc::new(AtomicUsize::new(0)); + let manifest_requests_for_task = Arc::clone(&manifest_requests); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); + let addr = listener.local_addr().expect("local addr"); + let task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let handler = Arc::clone(&handler); + let manifest_requests = Arc::clone(&manifest_requests_for_task); + tokio::spawn(async move { + let mut buf = vec![0_u8; 2048]; + let Ok(read) = socket.read(&mut buf).await else { + return; + }; + if read == 0 { + return; + } + let request = String::from_utf8_lossy(&buf[..read]); + let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("/"); + if path_has_extension(path, "m3u8") { + manifest_requests.fetch_add(1, Ordering::SeqCst); + } + let TestBinaryOriginResponse { status, location, body } = handler(path); + let reason = status.canonical_reason().unwrap_or("Status"); + let location_header = location.map_or_else(String::new, |location| format!("Location: {location}\r\n")); + let response = format!( + "HTTP/1.1 {} {reason}\r\n{location_header}Content-Length: {}\r\nConnection: close\r\n\r\n", + status.as_u16(), + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.write_all(&body).await; + }); + } + }); + TestSegmentOrigin { + base_url: format!("http://{addr}"), + key_requests, + manifest_requests, + segment_requests, + key_bytes: None, + task, + } +} + +async fn spawn_test_status_origin(status: StatusCode, body: &'static [u8]) -> TestSegmentOrigin { + let body = Arc::<[u8]>::from(body); + spawn_test_binary_origin(Arc::new(move |_path| TestBinaryOriginResponse::new(status, Arc::clone(&body)))).await +} + +struct TestEncodedManifestOrigin { + base_url: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for TestEncodedManifestOrigin { + fn drop(&mut self) { self.task.abort(); } +} + +async fn spawn_test_encoded_manifest_origin( + content_encoding: Option<&'static str>, + body: Vec, + body_delay: Duration, +) -> TestEncodedManifestOrigin { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); + let addr = listener.local_addr().expect("local addr"); + let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let task_requests = Arc::clone(&requests); + let body = Arc::new(body); + let task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let requests = Arc::clone(&task_requests); + let body = Arc::clone(&body); + tokio::spawn(async move { + let mut request = Vec::new(); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let mut chunk = [0_u8; 2048]; + let Ok(read) = socket.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + request.extend_from_slice(&chunk[..read]); + } + requests.lock().await.push(String::from_utf8_lossy(&request).to_string()); + let mut response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n", body.len()); + if let Some(content_encoding) = content_encoding { + let _ = writeln!(&mut response, "Content-Encoding: {content_encoding}\r"); + } + response.push_str("Connection: close\r\n\r\n"); + let _ = socket.write_all(response.as_bytes()).await; + if body_delay.is_zero() { + let _ = socket.write_all(body.as_slice()).await; + } else { + let split_at = body.len().min(4); + let _ = socket.write_all(&body[..split_at]).await; + tokio::time::sleep(body_delay).await; + let _ = socket.write_all(&body[split_at..]).await; + } + }); + } + }); + TestEncodedManifestOrigin { base_url: format!("http://{addr}"), requests, task } +} + +fn legacy_manifest_test_input(origin: &TestEncodedManifestOrigin) -> crate::model::InputSource { + crate::model::InputSource { + name: Arc::from("legacy-content-coding-test"), + url: format!("{}/manifest.m3u8", origin.base_url), + provider: None, + username: None, + password: None, + method: shared::model::InputFetchMethod::GET, + headers: HashMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]), + } +} + +fn legacy_manifest_test_client_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); + headers +} + +async fn encode_test_manifest(content_encoding: &str, body: &[u8]) -> Vec { + match content_encoding { + "gzip" => { + let mut encoder = async_compression::tokio::write::GzipEncoder::new(Vec::new()); + encoder.write_all(body).await.expect("gzip test body encodes"); + encoder.shutdown().await.expect("gzip test encoder finishes"); + encoder.into_inner() + } + "deflate" => { + let mut encoder = async_compression::tokio::write::DeflateEncoder::new(Vec::new()); + encoder.write_all(body).await.expect("deflate test body encodes"); + encoder.shutdown().await.expect("deflate test encoder finishes"); + encoder.into_inner() + } + "br" => { + let mut encoder = async_compression::tokio::write::BrotliEncoder::new(Vec::new()); + encoder.write_all(body).await.expect("brotli test body encodes"); + encoder.shutdown().await.expect("brotli test encoder finishes"); + encoder.into_inner() + } + "zstd" => { + let mut encoder = async_compression::tokio::write::ZstdEncoder::new(Vec::new()); + encoder.write_all(body).await.expect("zstd test body encodes"); + encoder.shutdown().await.expect("zstd test encoder finishes"); + encoder.into_inner() + } + _ => panic!("unsupported test Content-Encoding: {content_encoding}"), + } +} + +async fn wait_for_hls_test_session(app_state: &Arc, session_key: &HlsSessionKey) -> HlsSessionHandle { + for _ in 0..50 { + if let Some(session) = app_state.hls_proxy.sessions().get_by_key(session_key).await { + return session; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("shared hls test session should be created"); +} + +async fn wait_for_hls_refresh_in_flight(session: &HlsSessionHandle) { + for _ in 0..50 { + if session.read().await.origin_refresh.in_flight { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("shared hls refresh should be in flight"); +} + +struct TestTransientOrigin { + base_url: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for TestTransientOrigin { + fn drop(&mut self) { self.task.abort(); } +} + +async fn spawn_test_transient_origin() -> TestTransientOrigin { + spawn_test_transient_origin_with_response( + "206 Partial Content", + &[ + ("Content-Type", "video/mp2t"), + ("Content-Range", "bytes 2-15/16"), + ("Accept-Ranges", "bytes"), + ("Cache-Control", "no-store"), + ("ETag", "\"abc\""), + ("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"), + ], + "transient-body", + ) + .await +} + +async fn spawn_test_transient_origin_with_response( + status_line: &'static str, + response_headers: &'static [(&'static str, &'static str)], + body: &'static str, +) -> TestTransientOrigin { + spawn_test_transient_origin_with_delayed_binary_response( + status_line, + response_headers, + body.as_bytes().to_vec(), + Duration::ZERO, + ) + .await +} + +async fn spawn_test_transient_origin_with_delayed_response( + status_line: &'static str, + response_headers: &'static [(&'static str, &'static str)], + body: &'static str, + response_delay: Duration, +) -> TestTransientOrigin { + spawn_test_transient_origin_with_delayed_binary_response( + status_line, + response_headers, + body.as_bytes().to_vec(), + response_delay, + ) + .await +} + +async fn spawn_test_transient_origin_with_delayed_binary_response( + status_line: &'static str, + response_headers: &'static [(&'static str, &'static str)], + body: Vec, + response_delay: Duration, +) -> TestTransientOrigin { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds"); + let addr = listener.local_addr().expect("local addr"); + let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let task_requests = Arc::clone(&requests); + let body = Arc::new(body); + let task = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let requests = Arc::clone(&task_requests); + let body = Arc::clone(&body); + tokio::spawn(async move { + let mut buf = vec![0_u8; 4096]; + let Ok(read) = socket.read(&mut buf).await else { + return; + }; + if read == 0 { + return; + } + let request = String::from_utf8_lossy(&buf[..read]).to_string(); + requests.lock().await.push(request); + if !response_delay.is_zero() { + tokio::time::sleep(response_delay).await; + } + let mut response_head = format!("HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n", body.len()); + for (name, value) in response_headers { + let _ = writeln!(&mut response_head, "{name}: {value}\r"); + } + response_head.push_str("Connection: close\r\n\r\n"); + let _ = socket.write_all(response_head.as_bytes()).await; + let _ = socket.write_all(body.as_slice()).await; + }); + } + }); + TestTransientOrigin { base_url: format!("http://{addr}"), requests, task } +} + +#[tokio::test] +async fn valid_hls_proxy_segment_without_session_returns_not_found() { + let status = get_status(test_app_state(), "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/000123.ts").await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn valid_hls_proxy_segment_with_not_ready_session_returns_not_found() { + let app_state = test_app_state(); + let proxy_session_id = map_segment(&app_state, 123, "ts").await; + + let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/000123.ts")).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn valid_hls_proxy_segment_with_not_ready_and_valid_lease_returns_service_unavailable() { + let app_state = test_app_state(); + let proxy_session_id = map_segment(&app_state, 123, "ts").await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + session.write().await.segments.get_mut(&123).expect("segment should exist").origin_fetch_ref = None; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn not_ready_hls_proxy_segment_with_fetch_ref_demand_fetches_and_returns_ok() { + let origin = spawn_test_segment_origin(b"0123456789").await; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = + map_segment_with_origin_url(&app_state, 123, "ts", &format!("{}/seg.ts", origin.base_url)).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); +} + +#[tokio::test] +async fn not_ready_hls_proxy_segment_with_range_waits_for_demand_fetch_then_returns_partial() { + let origin = spawn_test_segment_origin(b"0123456789").await; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = + map_segment_with_origin_url(&app_state, 123, "ts", &format!("{}/seg.ts", origin.base_url)).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(app_state, &uri, Some("bytes=2-5")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); +} + +#[tokio::test] +async fn not_ready_hls_proxy_segment_without_fetch_ref_returns_service_unavailable() { + let app_state = test_app_state(); + let proxy_session_id = map_segment(&app_state, 123, "ts").await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + session.write().await.segments.get_mut(&123).expect("segment should exist").origin_fetch_ref = None; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn ready_hls_proxy_segment_without_lease_returns_not_found() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment_without_lease(&app_state, 123, "ts", b"0123456789").await; + + let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/000123.ts")).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn invalid_normal_segment_uri_never_redirects_or_serves_terminal_media() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "broken.ts").await; + + let response = get_response(app_state, &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_marked_for_gc_returns_not_found_without_redirect() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + session.write().await.mark_for_gc_removal(); + + let response = get_response(app_state, &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_without_range_returns_ok() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp2t"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); + assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=300, immutable"); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + { + let session = session.read().await; + let segment = session.segments.get(&123).expect("segment should exist"); + assert_eq!(segment.access.active_readers(), 1); + assert!(matches!(segment.status, SegmentCacheStatus::Ready { content_length: 10, .. })); + } + + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); + + { + let session = session.read().await; + let segment = session.segments.get(&123).expect("segment should exist"); + assert_eq!(segment.access.active_readers(), 0); + assert!(segment.access.last_accessed_at_ms() > 0); + assert!(session.activity.last_authorized_media_at_ms.is_some()); + } + assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; +} + +#[tokio::test] +async fn ready_hls_proxy_segment_range_zero_open_returns_partial_content() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "m4s", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.m4s").await; + + let response = get_response(app_state, &uri, Some("bytes=0-")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 0-9/10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_range_start_open_returns_partial_content_from_offset() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "m4v", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.m4v").await; + + let response = get_response(app_state, &uri, Some("bytes=4-")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 4-9/10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "6"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"456789")); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_range_start_end_returns_partial_content() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(app_state, &uri, Some("bytes=2-5")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_suffix_range_returns_partial_content() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(app_state, &uri, Some("bytes=-3")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 7-9/10"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"789")); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_unsatisfiable_range_returns_416() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=99-")).await; + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); + assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn ready_hls_proxy_segment_multi_range_returns_416() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-1,4-5")).await; + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn ready_hls_proxy_map_without_lease_returns_not_found() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_hls_map(&app_state, b"map-body", false).await; + + let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/map/000000.mp4")).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn ready_hls_proxy_map_without_range_returns_ok() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp4"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); + assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); + assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; +} + +#[tokio::test] +async fn ready_hls_proxy_map_range_returns_partial_content() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; + + let response = get_response(app_state, &uri, Some("bytes=2-5")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"2345")); +} + +#[tokio::test] +async fn ready_hls_proxy_map_multi_range_returns_416_with_zero_content_length() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_hls_map(&app_state, b"0123456789", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-1,4-5")).await; + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn hls_proxy_map_not_ready_with_valid_lease_returns_service_unavailable() { + let app_state = test_app_state(); + let session = + app_state.hls_proxy.get_or_create_session(HlsSessionKey::new(1, "12345"), b"rewrite-secret", 100).await; + let manifest = normal_manifest("#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4.0,\n000123.m4s\n"); + let proxy_session_id = { + let mut session = session.write().await; + session.apply_origin_manifest(&manifest).expect("manifest should map"); + session.proxy_session_id.0.clone() + }; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "map/000000.mp4").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn transient_resource_without_lease_returns_not_found() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", false).await; + + let status = get_status(app_state, &format!("/hls/shared/live/{proxy_session_id}/r/{resource_id}.ts")).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn transient_resource_with_valid_lease_streams_origin_response_and_headers() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&ProxySessionId(proxy_session_id.clone())) + .await + .expect("session should exist"); + { + let mut session = session.write().await; + session.origin_request_headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret")); + session.origin_request_headers.insert(header::COOKIE, HeaderValue::from_static("sid=secret")); + session + .origin_request_headers + .insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); + session.origin_request_headers.insert(header::HOST, HeaderValue::from_static("proxy.example.com")); + session + .origin_request_headers + .insert(HeaderName::from_static("x-tuliprox-main-revision"), HeaderValue::from_static("secret")); + session.origin_request_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("de")); + } + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_TYPE], "video/mp2t"); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-15/16"); + assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[header::ETAG], "\"abc\""); + assert_eq!(response.headers()[header::LAST_MODIFIED], "Wed, 21 Oct 2015 07:28:00 GMT"); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"transient-body")); + assert!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await.is_some()); + let origin_requests = origin.requests.lock().await; + let origin_request = origin_requests.first().expect("origin request").to_ascii_lowercase(); + assert!(origin_request.contains("range: bytes=2-15")); + assert!(origin_request.contains("accept-language: de")); + assert!(!origin_request.contains("authorization: bearer secret")); + assert!(!origin_request.contains("cookie: sid=secret")); + assert!(!origin_request.contains("proxy-authorization: basic secret")); + assert!(!origin_request.contains("host: proxy.example.com")); + assert!(!origin_request.contains("x-tuliprox-main-revision")); + assert_hls_cache_stream_registered(&app_state, &proxy_session_id).await; +} + +#[tokio::test] +async fn transient_resource_without_range_is_cached_after_first_fetch() { + let app_state = test_app_state(); + let origin = + spawn_test_transient_origin_with_response("200 OK", &[("Content-Type", "video/mp2t")], "0123456789").await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let first = get_response(Arc::clone(&app_state), &uri, None).await; + let second = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); + assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"0123456789")); + assert_eq!(origin.requests.lock().await.len(), 1); +} + +#[tokio::test] +async fn transient_resource_range_from_zero_is_cached_as_full_object() { + let app_state = test_app_state(); + let origin = + spawn_test_transient_origin_with_response("200 OK", &[("Content-Type", "video/mp2t")], "0123456789").await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let first = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-")).await; + let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=4-")).await; + + assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); + assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"456789")); + let requests = origin.requests.lock().await; + assert_eq!(requests.len(), 1); + let request = requests[0].to_ascii_lowercase(); + assert!(request.contains("accept-encoding: identity")); + assert!(!request.contains("\r\nrange:")); +} + +#[tokio::test] +async fn transient_cache_fill_rejects_identity_partial_without_ready_object_or_temp_file() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(Arc::clone(&hls_proxy)); + disable_custom_stream_response(&app_state); + let origin = spawn_test_transient_origin_with_response( + "206 Partial Content", + &[("Content-Type", "video/mp2t"), ("Content-Range", "bytes 0-3/10")], + "part", + ) + .await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let proxy_session_id = ProxySessionId(proxy_session_id); + let cache_key = TransientObjectCacheKey::new(proxy_session_id.clone(), TransientResourceId(resource_id), "ts"); + let session = hls_proxy.sessions().get_by_proxy_session_id(&proxy_session_id).await.expect("session should exist"); + let status = + session.read().await.transient.object_cache.get(&cache_key).expect("transient cache entry").status.clone(); + assert!(matches!(status, TransientObjectCacheStatus::FailedPermanent { status: None, .. })); + assert!(hls_proxy.segment_cache().metadata(&cache_key).await.expect("cache metadata reads").is_none()); + assert!(!hls_proxy.segment_cache().has_active_temp_files()); + assert_eq!(std::fs::read_dir(temp_dir.path()).expect("cache root reads").count(), 0); + + let requests = origin.requests.lock().await; + assert_eq!(requests.len(), 1); + let request = requests[0].to_ascii_lowercase(); + assert!(request.contains("accept-encoding: identity")); + assert!(!request.contains("\r\nrange:")); +} + +#[tokio::test] +async fn transient_resource_range_from_zero_waits_for_inflight_object_cache_fetch() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin_with_delayed_response( + "200 OK", + &[("Content-Type", "video/mp2t")], + "0123456789", + Duration::from_millis(150), + ) + .await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let first_app_state = Arc::clone(&app_state); + let first_uri = uri.clone(); + let first = tokio::spawn(async move { get_response(first_app_state, &first_uri, Some("bytes=0-")).await }); + for _ in 0..50 { + if origin.requests.lock().await.len() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert_eq!(origin.requests.lock().await.len(), 1); + + let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=0-")).await; + let first = first.await.expect("first request joins"); + + assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"0123456789")); + assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"0123456789")); + assert_eq!(origin.requests.lock().await.len(), 1); +} + +#[tokio::test] +async fn transient_resource_range_from_offset_without_ready_object_is_not_cached() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let first = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; + let second = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; + + assert_eq!(first.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(second.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(origin.requests.lock().await.len(), 2); +} + +#[tokio::test] +async fn transient_key_resource_is_not_cached() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin_with_response( + "200 OK", + &[("Content-Type", "application/octet-stream")], + "key-bytes", + ) + .await; + let (proxy_session_id, resource_id) = map_transient_resource_with_kind( + &app_state, + &format!("{}/key.bin", origin.base_url), + "key", + true, + TransientResourceKind::Key, + ) + .await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.key")).await; + + let first = get_response(Arc::clone(&app_state), &uri, None).await; + let second = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + assert_eq!(response_body(first).await, bytes::Bytes::from_static(b"key-bytes")); + assert_eq!(response_body(second).await, bytes::Bytes::from_static(b"key-bytes")); + assert_eq!(origin.requests.lock().await.len(), 2); +} + +#[tokio::test] +async fn transient_resource_origin_error_does_not_mark_media_activity() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin_with_response( + "500 Internal Server Error", + &[("Content-Type", "text/plain")], + "origin-error", + ) + .await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(header::RETRY_AFTER).and_then(|value| value.to_str().ok()), Some("1")); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn transient_resource_permanent_origin_error_never_redirects_to_manifest() { + let app_state = test_app_state(); + let origin = + spawn_test_transient_origin_with_response("404 Not Found", &[("Content-Type", "text/plain")], "missing").await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn transient_resource_permanent_origin_error_returns_not_found_when_custom_response_disabled() { + let app_state = test_app_state(); + disable_custom_stream_response(&app_state); + let origin = + spawn_test_transient_origin_with_response("404 Not Found", &[("Content-Type", "text/plain")], "missing").await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn transient_resource_holds_provider_handle_until_origin_body_is_finished() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let proxy_session_id_value = ProxySessionId(proxy_session_id.clone()); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id_value) + .await + .expect("session should exist"); + { + let mut session = session.write().await; + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::clone(&input.name), + Arc::from("account-a"), + &proxy_session_id_value, + super::current_time_millis(), + )); + } + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-15")).await; + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + wait_for_provider_connection_count(&app_state, 1).await; + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"transient-body")); + wait_for_provider_connection_count(&app_state, 0).await; +} + +#[tokio::test] +async fn transient_decoder_failure_releases_origin_and_access_guards_once() { + let input = overlap_provider_input(); + let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]); + let mut truncated = encode_test_manifest("gzip", b"transient decoder failure").await; + truncated.truncate(truncated.len().saturating_sub(8)); + let origin = spawn_test_transient_origin_with_delayed_binary_response( + "200 OK", + &[("Content-Type", "video/mp2t"), ("Content-Encoding", "gzip")], + truncated, + Duration::ZERO, + ) + .await; + let (proxy_session_id, resource_id) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let proxy_session_id_value = ProxySessionId(proxy_session_id.clone()); + let session = app_state + .hls_proxy + .sessions() + .get_by_proxy_session_id(&proxy_session_id_value) + .await + .expect("session should exist"); + { + let mut session = session.write().await; + session.origin_account_binding = Some(HlsOriginAccountBinding::new( + Arc::clone(&input.name), + Arc::from("account-a"), + &proxy_session_id_value, + super::current_time_millis(), + )); + } + let resource = session + .read() + .await + .transient + .resources + .get(&TransientResourceId(resource_id.clone())) + .cloned() + .expect("transient resource exists"); + let uri = hls_proxy_uri(&app_state, &proxy_session_id, &format!("r/{resource_id}.ts")).await; + + let response = get_response(Arc::clone(&app_state), &uri, Some("bytes=2-")).await; + + assert_eq!(response.status(), StatusCode::OK); + wait_for_provider_connection_count(&app_state, 1).await; + assert_eq!(resource.active_readers(), 1); + assert!(response.into_body().collect().await.is_err()); + wait_for_provider_connection_count(&app_state, 0).await; + assert_eq!(resource.active_readers(), 0); + tokio::task::yield_now().await; + assert_eq!(app_state.active_provider.get_provider_connections_count().await, 0); + assert_eq!(origin.requests.lock().await.len(), 1, "body failure must not start another origin request"); +} + +#[tokio::test] +async fn transient_unknown_resource_never_redirects_to_manifest() { + let app_state = test_app_state(); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, _) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "r/unknown.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.headers().contains_key(header::LOCATION)); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[tokio::test] +async fn transient_unknown_resource_returns_not_found_when_custom_response_disabled() { + let app_state = test_app_state(); + disable_custom_stream_response(&app_state); + let origin = spawn_test_transient_origin().await; + let (proxy_session_id, _) = + map_transient_resource(&app_state, &format!("{}/seg.ts", origin.base_url), "ts", true).await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "r/unknown.ts").await; + + let response = get_response(Arc::clone(&app_state), &uri, None).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(hls_session_last_media_at_ms(&app_state, &proxy_session_id).await, None); + assert_no_hls_cache_stream_registered(&app_state).await; +} + +#[test] +fn transient_cross_origin_redirect_strips_sensitive_headers() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer secret")); + headers.insert(header::COOKIE, HeaderValue::from_static("session=secret")); + headers.insert(HeaderName::from_static("proxy-authorization"), HeaderValue::from_static("Basic secret")); + headers.insert(header::HOST, HeaderValue::from_static("origin.example.com")); + headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-1")); + + crate::api::model::scrub_hls_origin_headers(&mut headers, None); + + assert!(!headers.contains_key(header::AUTHORIZATION)); + assert!(!headers.contains_key(header::COOKIE)); + assert!(!headers.contains_key("proxy-authorization")); + assert!(!headers.contains_key(header::HOST)); + assert_eq!(headers[header::RANGE], "bytes=0-1"); +} + +#[tokio::test] +async fn ready_hls_proxy_segment_unknown_range_unit_is_ignored() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let hls_proxy = Arc::new(HlsProxyManager::with_cache_settings(temp_dir.path(), 300)); + let app_state = test_app_state_with_hls_proxy(hls_proxy); + let proxy_session_id = map_ready_segment(&app_state, 123, "ts", b"0123456789").await; + let uri = hls_proxy_uri(&app_state, &proxy_session_id, "000123.ts").await; + + let response = get_response(app_state, &uri, Some("items=0-1")).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body(response).await, bytes::Bytes::from_static(b"0123456789")); +} + +#[tokio::test] +async fn invalid_hls_proxy_file_names_return_not_found() { + let app_state = test_app_state(); + + assert_eq!( + get_status(Arc::clone(&app_state), "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/123.ts").await, + StatusCode::NOT_FOUND + ); + assert_eq!( + get_status(app_state, "/hls/shared/live/a8f31c9eQ7sLk92pV0mTaw/map/000123.exe").await, + StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn legacy_hls_route_remains_registered() { + let status = get_status(test_app_state(), "/hls/user/pass/1/2/3/not-a-token").await; + + assert_ne!(status, StatusCode::NOT_FOUND); +} diff --git a/backend/app/src/api/endpoints/m3u_api.rs b/backend/app/src/api/endpoints/m3u_api.rs index 621276d61..4af034b05 100644 --- a/backend/app/src/api/endpoints/m3u_api.rs +++ b/backend/app/src/api/endpoints/m3u_api.rs @@ -60,9 +60,10 @@ async fn m3u_api( }) }); - let mut builder = axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, mime::TEXT_PLAIN_UTF_8.to_string()); + let mut builder = axum::response::Response::builder().status(axum::http::StatusCode::OK).header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); if content_type == "m3u_plus" { builder = builder.header(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"playlist.m3u\""); diff --git a/backend/app/src/api/endpoints/v1_api_playlist.rs b/backend/app/src/api/endpoints/v1_api_playlist.rs index 51b3caec0..45cfe24bc 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist.rs @@ -1451,6 +1451,7 @@ mod tests { shared_stream_manager, hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, diff --git a/backend/app/src/api/endpoints/xtream_api.rs b/backend/app/src/api/endpoints/xtream_api/mod.rs similarity index 67% rename from backend/app/src/api/endpoints/xtream_api.rs rename to backend/app/src/api/endpoints/xtream_api/mod.rs index 9fd0089f1..84f5b4209 100644 --- a/backend/app/src/api/endpoints/xtream_api.rs +++ b/backend/app/src/api/endpoints/xtream_api/mod.rs @@ -1976,875 +1976,4 @@ pub fn xtream_api_register() -> axum::Router> { } #[cfg(test)] -mod tests { - use super::{ - empty_stream_info_response, get_xtream_player_api_stream_url, is_hls_playback_request, - override_live_hls_extension, recording_input_matches, resolve_m3u_xtream_timeshift, - resolve_xtream_playback_extension, xtream_get_short_epg, xtream_player_api_stream, - xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest, XtreamApiTimeShiftRequest, - }; - use crate::{ - api::model::{create_test_app_state, AppState, PlaylistStorage, PlaylistXtreamStorage, UserApiRequest}, - auth::Fingerprint, - model::{ - Config, ConfigInput, ConfigTarget, Epg, IcsEpgSourceConfig, ProxyUserCredentials, SourcesConfig, - TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput, - }, - processing::parser::ics::parse_ics_file_to_channel, - repository::{ - epg_write_file, xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTree, VirtualIdRecord, - }, - }; - use arc_swap::ArcSwapOption; - use axum::{http::HeaderMap, response::IntoResponse}; - use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; - use shared::{ - foundation::Filter, - model::{ - ClusterFlags, InputType, PlaylistItemType, ProcessingOrder, ProxyUserStatus, StreamProperties, UUIDType, - VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, - }, - utils::Internable, - }; - use std::sync::Arc; - use tempfile::tempdir; - - #[test] - fn recording_input_must_match_canonical_playlist_item_input() { - let expected = ConfigInput { name: "input-a".intern(), ..Default::default() }; - assert!(recording_input_matches(Some(&expected), "input-a")); - assert!(!recording_input_matches(Some(&expected), "input-b")); - assert!(recording_input_matches(None, "input-b")); - } - - #[test] - fn live_hls_override_is_scoped_to_enabled_xtream_live_requests() { - let enabled_xtream = provider_input_with_flag(InputType::Xtream, true); - let disabled_xtream = provider_input_with_flag(InputType::Xtream, false); - let enabled_m3u = provider_input_with_flag(InputType::M3u, true); - let cases = [ - (ApiStreamContext::Live, &enabled_xtream, Some(".m3u8"), Some(".ts")), - (ApiStreamContext::LiveAlt, &enabled_xtream, Some(".m3u8"), Some(".ts")), - (ApiStreamContext::Live, &disabled_xtream, Some(".m3u8"), Some(".m3u8")), - (ApiStreamContext::Live, &enabled_m3u, Some(".m3u8"), Some(".m3u8")), - (ApiStreamContext::Timeshift, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), - (ApiStreamContext::Movie, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), - (ApiStreamContext::Series, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), - (ApiStreamContext::Live, &enabled_xtream, Some(".ts"), Some(".ts")), - (ApiStreamContext::Live, &enabled_xtream, Some(".mpd"), Some(".mpd")), - (ApiStreamContext::Live, &enabled_xtream, None, None), - ]; - - for (context, input, extension, expected) in cases { - assert_eq!( - override_live_hls_extension(context, input, extension), - expected, - "case: {context:?} input_type={:?} ext={extension:?}", - input.input_type - ); - } - } - - fn provider_input_with_flag(input_type: InputType, disable_hls: bool) -> ConfigInput { - let dto = shared::model::ConfigInputDto { - name: "ts-provider".into(), - input_type, - url: "http://provider.test".to_string(), - username: Some("user".to_string()), - password: Some("pass".to_string()), - enabled: true, - options: Some(shared::model::ConfigInputOptionsDto { - disable_hls_streaming: disable_hls, - ..shared::model::ConfigInputOptionsDto::default() - }), - ..shared::model::ConfigInputDto::default() - }; - ConfigInput::from(&dto) - } - - #[test] - fn hls_failure_response_uses_resolved_playback_extension() { - let vod = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); - let hls_vod = - create_test_vod_item("provider://strong/movie/user/pass/813564.m3u8", "m3u8", PlaylistItemType::Video); - - assert!(!is_hls_playback_request(Some(".m3u8"), &vod)); - assert!(is_hls_playback_request(None, &hls_vod)); - } - - async fn response_body_text(response: axum::response::Response) -> Result { - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .map_err(|err| format!("failed to read response body: {err}"))?; - String::from_utf8(body.to_vec()).map_err(|err| format!("response body is not UTF-8: {err}")) - } - - fn short_epg_target() -> ConfigTarget { - ConfigTarget { - id: 1, - enabled: true, - name: "ics-xtream".to_string(), - options: None, - sort: None, - filter: Filter::default().into(), - 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(), - execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), - watch: None, - use_memory_cache: true, - } - } - - fn short_epg_live_item() -> XtreamPlaylistItem { - XtreamPlaylistItem { - virtual_id: VirtualId::new(100), - provider_id: 0, - name: "Formula 1".intern(), - logo: "".intern(), - logo_small: "".intern(), - group: "Sports".intern(), - title: "".intern(), - parent_code: "".intern(), - rec: "".intern(), - url: "http://example.invalid/live.ts".intern(), - epg_channel_id: Some("f1.calendar".intern()), - xtream_cluster: XtreamCluster::Live, - additional_properties: None, - item_type: PlaylistItemType::Live, - category_id: 1, - input_name: "local".intern(), - channel_no: 1, - source_ordinal: 0, - input_stream_id: "".intern(), - upstream_user_agent: None, - } - } - - #[tokio::test] - async fn xtream_short_epg_returns_imported_ics_programme_for_matching_channel_id() { - let dir = tempdir().expect("temp dir"); - let ics_path = dir.path().join("calendar.ics"); - let start = chrono::Utc::now() + chrono::Duration::days(1); - let stop = start + chrono::Duration::hours(1); - std::fs::write( - &ics_path, - format!( - concat!( - "BEGIN:VCALENDAR\r\n", - "VERSION:2.0\r\n", - "BEGIN:VEVENT\r\n", - "UID:f1-qualifying\r\n", - "DTSTART:{}\r\n", - "DTEND:{}\r\n", - "SUMMARY:Formula 1 Qualifying\r\n", - "DESCRIPTION:Imported from ICS\r\n", - "END:VEVENT\r\n", - "END:VCALENDAR\r\n", - ), - start.format("%Y%m%dT%H%M%SZ"), - stop.format("%Y%m%dT%H%M%SZ"), - ), - ) - .expect("write ICS fixture"); - let channel = parse_ics_file_to_channel( - &ics_path, - "f1.calendar".intern(), - Some("Formula 1".intern()), - &IcsEpgSourceConfig::default(), - ) - .await - .expect("parse ICS fixture"); - - let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; - let target = Arc::new(short_epg_target()); - let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); - std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); - let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); - epg_write_file( - &target.name, - &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, - &epg_path, - &std::collections::HashMap::, Arc>::new(), - &shared::model::EpgOutputOptions::default(), - ) - .expect("write target EPG"); - - let app_state = create_test_app_state(config); - let live_item = short_epg_live_item(); - let mut live = BPlusTree::new(); - live.insert(live_item.virtual_id.get(), live_item.clone()); - app_state - .playlists - .cache_playlist( - &target.name, - PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { - live, - vod: BPlusTree::new(), - series: BPlusTree::new(), - })), - ) - .await; - let mut id_mapping = BPlusTree::new(); - id_mapping.insert( - live_item.virtual_id, - VirtualIdRecord::new( - live_item.provider_id, - live_item.virtual_id, - PlaylistItemType::Live, - VirtualId::new(0), - UUIDType::default(), - ), - ); - app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; - - let mut user = ProxyUserCredentials::default(); - user.output_clusters = ClusterFlags::all(); - let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); - let body = response_body_text(response).await.expect("read short EPG response"); - let json: serde_json::Value = serde_json::from_str(&body).expect("parse short EPG JSON"); - let decode_listing_text = |field: &str| { - let encoded = json["epg_listings"][0][field].as_str().expect("encoded listing text"); - String::from_utf8(BASE64_STANDARD.decode(encoded).expect("decode listing text")) - .expect("decoded listing text is UTF-8") - }; - - assert_eq!(json["epg_listings"][0]["channel_id"], "f1.calendar"); - assert_eq!(decode_listing_text("title"), "Formula 1 Qualifying"); - assert_eq!(decode_listing_text("description"), "Imported from ICS"); - } - - fn m3u_catchup_item(name: &str, input_name: &str, url: &str, catchup_source: Option<&str>) -> XtreamPlaylistItem { - XtreamPlaylistItem { - virtual_id: VirtualId::new(100), - provider_id: 0, - name: name.intern(), - logo: "".intern(), - logo_small: "".intern(), - group: "Live".intern(), - title: "".intern(), - parent_code: "".intern(), - rec: "".intern(), - url: url.intern(), - epg_channel_id: Some("f1.calendar".intern()), - xtream_cluster: XtreamCluster::Live, - item_type: PlaylistItemType::Live, - category_id: 1, - input_name: input_name.intern(), - channel_no: 1, - source_ordinal: 0, - input_stream_id: "100".intern(), - additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - name: name.intern(), - stream_id: 100, - tv_archive: Some(1), - tv_archive_duration: Some(7), - catchup: catchup_source.map(|src| shared::model::CatchupProperties { - mode: Some("flussonic".intern()), - source: Some(src.intern()), - ..shared::model::CatchupProperties::default() - }), - ..shared::model::LiveStreamProperties::default() - }))), - upstream_user_agent: None, - } - } - - async fn cache_xtream_test_item( - app_state: &Arc, - target: &Arc, - item: XtreamPlaylistItem, - input: Option, - ) { - if let Some(input) = input { - let sources = SourcesConfig { - inputs: vec![Arc::new(input)], - sources: vec![crate::model::ConfigSource { - inputs: vec![item.input_name.clone()], - targets: vec![target.clone()], - }], - ..SourcesConfig::default() - }; - app_state.app_config.sources.store(Arc::new(sources)); - } - let mut live = BPlusTree::new(); - live.insert(item.virtual_id.get(), item.clone()); - app_state - .playlists - .cache_playlist( - &target.name, - PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { - live, - vod: BPlusTree::new(), - series: BPlusTree::new(), - })), - ) - .await; - let mut id_mapping = BPlusTree::new(); - id_mapping.insert( - item.virtual_id, - VirtualIdRecord::new( - item.provider_id, - item.virtual_id, - PlaylistItemType::Live, - VirtualId::new(0), - UUIDType::default(), - ), - ); - app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; - } - - async fn build_short_epg_app_state( - dir: &tempfile::TempDir, - item: XtreamPlaylistItem, - input: Option, - ) -> (Arc, Arc) { - let ics_path = dir.path().join("calendar.ics"); - let start = chrono::Utc::now() + chrono::Duration::days(1); - let stop = start + chrono::Duration::hours(1); - std::fs::write( - &ics_path, - format!( - concat!( - "BEGIN:VCALENDAR\r\n", - "VERSION:2.0\r\n", - "BEGIN:VEVENT\r\n", - "UID:f1-qualifying\r\n", - "DTSTART:{}\r\n", - "DTEND:{}\r\n", - "SUMMARY:Formula 1 Qualifying\r\n", - "DESCRIPTION:Imported from ICS\r\n", - "END:VEVENT\r\n", - "END:VCALENDAR\r\n", - ), - start.format("%Y%m%dT%H%M%SZ"), - stop.format("%Y%m%dT%H%M%SZ"), - ), - ) - .expect("write ICS fixture"); - let channel = parse_ics_file_to_channel( - &ics_path, - "f1.calendar".intern(), - Some("Formula 1".intern()), - &IcsEpgSourceConfig::default(), - ) - .await - .expect("parse ICS fixture"); - - let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; - let target = Arc::new(short_epg_target()); - let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); - std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); - let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); - epg_write_file( - &target.name, - &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, - &epg_path, - &std::collections::HashMap::, Arc>::new(), - &shared::model::EpgOutputOptions::default(), - ) - .expect("write target EPG"); - - let app_state = create_test_app_state(config); - cache_xtream_test_item(&app_state, &target, item, input).await; - - (app_state, target) - } - - #[tokio::test] - async fn xtream_short_epg_emits_has_archive_for_m3u_with_bridge_template() { - let dir = tempdir().expect("temp dir"); - let input = m3u_timeshift_input(); - let item = m3u_catchup_item( - "Formula 1", - &input.name, - "channel/index.m3u8", - Some("http://provider.example/channel/video-{utc}-{duration}.m3u8"), - ); - let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; - let mut user = ProxyUserCredentials::default(); - user.output_clusters = ClusterFlags::all(); - - let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); - let body = response_body_text(response).await.expect("body"); - let json: serde_json::Value = serde_json::from_str(&body).expect("json"); - - assert_eq!(json["epg_listings"][0]["has_archive"], 1); - } - - #[tokio::test] - async fn xtream_short_epg_omits_has_archive_for_m3u_with_unsupported_catchup() { - let dir = tempdir().expect("temp dir"); - let input = m3u_timeshift_input(); - let item = m3u_catchup_item( - "Formula 1", - &input.name, - "channel/index.m3u8", - Some("http://provider.example/channel/${timestamp}.m3u8"), - ); - let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; - let mut user = ProxyUserCredentials::default(); - user.output_clusters = ClusterFlags::all(); - - let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) - .await - .expect("body"); - let json: serde_json::Value = serde_json::from_str(&body).expect("json"); - - assert!(json["epg_listings"][0].get("has_archive").is_none()); - } - - #[tokio::test] - async fn xtream_short_epg_emits_has_archive_for_native_xtream_input() { - let dir = tempdir().expect("temp dir"); - let mut input = m3u_timeshift_input(); - input.input_type = InputType::Xtream; - let item = m3u_catchup_item("Formula 1", &input.name, "channel/index.m3u8", None); - let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; - let mut user = ProxyUserCredentials::default(); - user.output_clusters = ClusterFlags::all(); - - let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) - .await - .expect("body"); - let json: serde_json::Value = serde_json::from_str(&body).expect("json"); - - assert_eq!(json["epg_listings"][0]["has_archive"], 1); - } - - #[tokio::test] - async fn empty_stream_info_response_uses_vod_object_and_list_shapes() -> Result<(), String> { - assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Video)).await?, "{}"); - assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Live)).await?, "[]"); - assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Series)).await?, "[]"); - Ok(()) - } - - fn create_test_vod_item(url: &str, container_extension: &str, item_type: PlaylistItemType) -> XtreamPlaylistItem { - XtreamPlaylistItem { - virtual_id: VirtualId::new(176_141), - provider_id: 813_563, - name: "Test".intern(), - logo: "".intern(), - logo_small: "".intern(), - group: "".intern(), - title: "".intern(), - parent_code: "".intern(), - rec: "".intern(), - url: Arc::::from(url), - epg_channel_id: None, - xtream_cluster: XtreamCluster::Video, - additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { - name: "Test".intern(), - category_id: 0, - stream_id: 813_563, - stream_icon: "".intern(), - direct_source: "".intern(), - custom_sid: None, - added: "".intern(), - container_extension: container_extension.intern(), - rating: None, - rating_5based: None, - stream_type: Some("movie".intern()), - trailer: None, - tmdb: None, - is_adult: 0, - details: None, - }))), - item_type, - category_id: 0, - input_name: "strong".intern(), - channel_no: 0, - source_ordinal: 0, - input_stream_id: "813563".intern(), - upstream_user_agent: None, - } - } - - #[test] - fn post_query_only_request_prefers_query_when_form_is_missing() { - let api_query_req = UserApiRequest { - username: String::from("query-user"), - password: String::from("query-pass"), - action: String::from("get_live_streams"), - ..UserApiRequest::default() - }; - - let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); - - assert_eq!(api_req.username, "query-user"); - assert_eq!(api_req.password, "query-pass"); - assert_eq!(api_req.action, "get_live_streams"); - } - - #[test] - fn post_request_prefers_query_over_form() { - let api_query_req = UserApiRequest { - username: String::from("query-user"), - action: String::from("query-action"), - ..UserApiRequest::default() - }; - let form_req = UserApiRequest { - username: String::from("form-user"), - action: String::from("form-action"), - ..UserApiRequest::default() - }; - - let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); - - assert_eq!(api_req.username, "query-user"); - assert_eq!(api_req.action, "query-action"); - } - - #[test] - fn timeshift_query_request_prefers_query_when_form_is_missing() { - let api_query_req = UserApiRequest { - username: String::from("query-user"), - password: String::from("query-pass"), - stream: String::from("42"), - duration: String::from("60"), - start: String::from("2024-01-01:00-00"), - ..UserApiRequest::default() - }; - let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); - - assert_eq!(api_req.username, "query-user"); - assert_eq!(api_req.password, "query-pass"); - assert_eq!(api_req.stream, "42"); - assert_eq!(api_req.duration, "60"); - assert_eq!(api_req.start, "2024-01-01:00-00"); - } - - #[test] - fn timeshift_query_request_prefers_query_over_form() { - let api_query_req = UserApiRequest { - username: String::from("query-user"), - stream: String::from("42"), - duration: String::from("60"), - start: String::from("2024-01-01:00-00"), - ..UserApiRequest::default() - }; - let form_req = UserApiRequest { - username: String::from("form-user"), - stream: String::from("99"), - duration: String::from("10"), - start: String::from("form-start"), - ..UserApiRequest::default() - }; - - let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); - - assert_eq!(api_req.username, "query-user"); - assert_eq!(api_req.stream, "42"); - assert_eq!(api_req.duration, "60"); - assert_eq!(api_req.start, "2024-01-01:00-00"); - } - - #[test] - fn timeshift_path_request_prefers_query_when_form_is_missing() { - let timeshift_request = XtreamApiTimeShiftRequest { - username: String::new(), - password: String::new(), - duration: String::new(), - start: String::new(), - stream_id: String::new(), - }; - let api_query_req = UserApiRequest { - username: String::from("query-user"), - password: String::from("query-pass"), - stream_id: String::from("42"), - duration: String::from("60"), - start: String::from("2024-01-01:00-00"), - ..UserApiRequest::default() - }; - let query_req = UserApiRequest::merge_query_over_form(&api_query_req, None); - let path_req = UserApiRequest { - username: timeshift_request.username, - password: timeshift_request.password, - duration: timeshift_request.duration, - start: timeshift_request.start, - stream_id: timeshift_request.stream_id, - ..UserApiRequest::default() - }; - let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); - - assert_eq!(api_req.username, "query-user"); - assert_eq!(api_req.password, "query-pass"); - assert_eq!(api_req.stream_id, "42"); - assert_eq!(api_req.duration, "60"); - assert_eq!(api_req.start, "2024-01-01:00-00"); - } - - #[test] - fn timeshift_path_request_prefers_path_over_query_and_form() { - let timeshift_request = XtreamApiTimeShiftRequest { - username: String::from("path-user"), - password: String::from("path-pass"), - duration: String::from("120"), - start: String::from("path-start"), - stream_id: String::from("7"), - }; - let api_query_req = UserApiRequest { - username: String::from("query-user"), - password: String::from("query-pass"), - stream_id: String::from("42"), - duration: String::from("60"), - start: String::from("query-start"), - ..UserApiRequest::default() - }; - let form_req = UserApiRequest { - username: String::from("form-user"), - password: String::from("form-pass"), - stream_id: String::from("99"), - duration: String::from("10"), - start: String::from("form-start"), - ..UserApiRequest::default() - }; - - let query_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); - let path_req = UserApiRequest { - username: timeshift_request.username, - password: timeshift_request.password, - duration: timeshift_request.duration, - start: timeshift_request.start, - stream_id: timeshift_request.stream_id, - ..UserApiRequest::default() - }; - let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); - - assert_eq!(api_req.username, "path-user"); - assert_eq!(api_req.password, "path-pass"); - assert_eq!(api_req.stream_id, "7"); - assert_eq!(api_req.duration, "120"); - assert_eq!(api_req.start, "path-start"); - } - - #[test] - fn non_live_playback_extension_prefers_canonical_item_extension_over_client_override() { - let pli = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); - - assert_eq!(resolve_xtream_playback_extension(Some(".mkv"), &pli).as_deref(), Some(".mp4")); - } - - #[test] - fn media_server_xtream_playback_uses_internal_stream_ref_even_with_direct_pms_url_credentials() { - let input = ConfigInput { - input_type: InputType::Plex, - url: "http://pms-user:pms-pass@pms.example.invalid:32400".to_string(), - ..ConfigInput::default() - }; - let fallback = - Arc::::from("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv"); - - let resolved = get_xtream_player_api_stream_url(&input, ApiStreamContext::Movie, "813563.mkv", &fallback) - .expect("media-server fallback should be preserved"); - - assert_eq!(resolved, fallback); - } - - #[test] - fn live_playback_extension_keeps_client_requested_extension() { - let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); - pli.xtream_cluster = XtreamCluster::Live; - - assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); - } - - #[test] - fn catchup_playback_extension_preserves_requested_adaptive_extension_for_underlying_live_item() { - let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); - pli.xtream_cluster = XtreamCluster::Live; - - assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); - } - - fn m3u_timeshift_input() -> ConfigInput { - ConfigInput { - id: 0, - name: "m3u-flussonic".intern(), - input_type: InputType::M3u, - url: "http://provider.example".to_string(), - username: Some("alice".to_string()), - password: Some("secret".to_string()), - ..ConfigInput::default() - } - } - - fn m3u_timeshift_item() -> XtreamPlaylistItem { - XtreamPlaylistItem { - virtual_id: VirtualId::new(100), - provider_id: 0, - name: "Live TV".intern(), - logo: "".intern(), - logo_small: "".intern(), - group: "Live".intern(), - title: "".intern(), - parent_code: "".intern(), - rec: "".intern(), - url: "channel/index.m3u8".intern(), - epg_channel_id: None, - xtream_cluster: XtreamCluster::Live, - additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - name: "Live TV".intern(), - stream_id: 100, - tv_archive: Some(1), - tv_archive_duration: Some(7), - catchup: Some(shared::model::CatchupProperties { - mode: Some("flussonic".intern()), - source: Some("http://provider.example/channel/video-{utc}-{duration}.m3u8".intern()), - ..shared::model::CatchupProperties::default() - }), - ..shared::model::LiveStreamProperties::default() - }))), - item_type: PlaylistItemType::Live, - category_id: 1, - input_name: "m3u-flussonic".intern(), - channel_no: 1, - source_ordinal: 0, - input_stream_id: "100".intern(), - upstream_user_agent: None, - } - } - - #[test] - fn m3u_timeshift_resolver_returns_resolved_url_for_flussonic_template() { - let input = m3u_timeshift_input(); - let item = m3u_timeshift_item(); - - let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") - .expect("resolver ok") - .expect("M3U input handled by bridge"); - - assert_eq!(resolved.url, "http://provider.example/channel/video-1704067200-3600.m3u8"); - assert!(!resolved.discriminator.is_empty()); - } - - #[test] - fn m3u_timeshift_resolver_distinguishes_time_windows() { - let input = m3u_timeshift_input(); - let item = m3u_timeshift_item(); - - let first = - resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect("first ok").expect("some"); - let second = - resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:01-00").expect("second ok").expect("some"); - - assert_ne!(first.url, second.url); - assert_ne!(first.discriminator, second.discriminator); - let fp = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()); - assert_ne!( - crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &first.discriminator), - crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &second.discriminator), - ); - } - - #[test] - fn m3u_timeshift_resolver_rejects_missing_catchup_metadata() { - let mut item = m3u_timeshift_item(); - if let Some(StreamProperties::Live(live)) = item.additional_properties.as_mut() { - live.catchup = None; - } - let input = m3u_timeshift_input(); - - let err = - resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect_err("missing catchup must error"); - assert_eq!(err.kind(), shared::error::ErrorKind::ApiXtream); - } - - #[test] - fn m3u_timeshift_resolver_returns_none_for_non_m3u_input() { - let mut input = m3u_timeshift_input(); - input.input_type = InputType::Xtream; - let item = m3u_timeshift_item(); - - let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") - .expect("non-M3U inputs are skipped, not errored"); - assert!(resolved.is_none(), "non-M3U input must be skipped by helper"); - } - - #[test] - fn m3u_timeshift_stream_url_uses_resolved_archive_for_credential_bearing_input() { - let input = m3u_timeshift_input(); - let resolved_url: Arc = Arc::from("http://provider.example/channel/video-1704067200-3600.m3u8"); - - let url = - get_xtream_player_api_stream_url(&input, ApiStreamContext::Timeshift, "60/2024-01-01:00-00", &resolved_url) - .expect("resolved archive URL must win for M3U timeshift"); - - assert_eq!(url, resolved_url); - assert!(!url.contains("alice")); - assert!(!url.contains("secret")); - } - - #[tokio::test] - async fn expired_user_is_rejected_before_m3u_timeshift_is_resolved() -> Result<(), String> { - let dir = tempdir().map_err(|err| err.to_string())?; - let input = m3u_timeshift_input(); - let item = m3u_timeshift_item(); - let config = Config { - storage_dir: dir.path().to_string_lossy().into_owned(), - user_access_control: true, - ..Config::default() - }; - let app_state = create_test_app_state(config); - let target = Arc::new(short_epg_target()); - cache_xtream_test_item(&app_state, &target, item, Some(input)).await; - - let mut user = ProxyUserCredentials::default(); - user.username = "expired".to_string(); - user.status = Some(ProxyUserStatus::Expired); - user.output_clusters = ClusterFlags::all(); - let user = Arc::new(user); - let addr = "127.0.0.1:0".parse().map_err(|err: std::net::AddrParseError| err.to_string())?; - let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr); - - let response = xtream_player_api_stream( - &fingerprint, - &HeaderMap::new(), - &app_state, - &UserApiRequest::default(), - ApiStreamRequest::from(ApiStreamContext::Timeshift, "expired", "", "100.ts", "invalid"), - Some((user, target)), - ) - .await - .into_response(); - - assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY); - Ok(()) - } - - #[tokio::test] - async fn xtream_token_stream_rejects_m3u_timeshift_with_bad_request() { - use crate::auth::create_access_token; - - let dir = tempdir().expect("temp dir"); - let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; - let app_state = create_test_app_state(config); - let target = Arc::new(short_epg_target()); - let input = m3u_timeshift_input(); - let item = m3u_timeshift_item(); - cache_xtream_test_item(&app_state, &target, item, Some(input)).await; - - let token = - create_access_token(&app_state.app_config.access_token_secret, 60, crate::auth::scope::INTERNAL_PLAYER); - - let response = xtream_player_api_stream_with_token( - &Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()), - &HeaderMap::new(), - &app_state, - target.id, - ApiStreamRequest::from_access_token(ApiStreamContext::Timeshift, &token, "100.ts", ""), - ) - .await - .into_response(); - - assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); - } -} +mod tests; diff --git a/backend/app/src/api/endpoints/xtream_api/tests.rs b/backend/app/src/api/endpoints/xtream_api/tests.rs new file mode 100644 index 000000000..5bbbf0366 --- /dev/null +++ b/backend/app/src/api/endpoints/xtream_api/tests.rs @@ -0,0 +1,867 @@ +use super::{ + empty_stream_info_response, get_xtream_player_api_stream_url, is_hls_playback_request, override_live_hls_extension, + recording_input_matches, resolve_m3u_xtream_timeshift, resolve_xtream_playback_extension, xtream_get_short_epg, + xtream_player_api_stream, xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest, + XtreamApiTimeShiftRequest, +}; +use crate::{ + api::model::{create_test_app_state, AppState, PlaylistStorage, PlaylistXtreamStorage, UserApiRequest}, + auth::Fingerprint, + model::{ + Config, ConfigInput, ConfigTarget, Epg, IcsEpgSourceConfig, ProxyUserCredentials, SourcesConfig, TargetOutput, + XtreamTargetFlagsSet, XtreamTargetOutput, + }, + processing::parser::ics::parse_ics_file_to_channel, + repository::{ + epg_write_file, xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTree, VirtualIdRecord, + }, +}; +use arc_swap::ArcSwapOption; +use axum::{http::HeaderMap, response::IntoResponse}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; +use shared::{ + foundation::Filter, + model::{ + ClusterFlags, InputType, PlaylistItemType, ProcessingOrder, ProxyUserStatus, StreamProperties, UUIDType, + VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, + }, + utils::Internable, +}; +use std::sync::Arc; +use tempfile::tempdir; + +#[test] +fn recording_input_must_match_canonical_playlist_item_input() { + let expected = ConfigInput { name: "input-a".intern(), ..Default::default() }; + assert!(recording_input_matches(Some(&expected), "input-a")); + assert!(!recording_input_matches(Some(&expected), "input-b")); + assert!(recording_input_matches(None, "input-b")); +} + +#[test] +fn live_hls_override_is_scoped_to_enabled_xtream_live_requests() { + let enabled_xtream = provider_input_with_flag(InputType::Xtream, true); + let disabled_xtream = provider_input_with_flag(InputType::Xtream, false); + let enabled_m3u = provider_input_with_flag(InputType::M3u, true); + let cases = [ + (ApiStreamContext::Live, &enabled_xtream, Some(".m3u8"), Some(".ts")), + (ApiStreamContext::LiveAlt, &enabled_xtream, Some(".m3u8"), Some(".ts")), + (ApiStreamContext::Live, &disabled_xtream, Some(".m3u8"), Some(".m3u8")), + (ApiStreamContext::Live, &enabled_m3u, Some(".m3u8"), Some(".m3u8")), + (ApiStreamContext::Timeshift, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), + (ApiStreamContext::Movie, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), + (ApiStreamContext::Series, &enabled_xtream, Some(".m3u8"), Some(".m3u8")), + (ApiStreamContext::Live, &enabled_xtream, Some(".ts"), Some(".ts")), + (ApiStreamContext::Live, &enabled_xtream, Some(".mpd"), Some(".mpd")), + (ApiStreamContext::Live, &enabled_xtream, None, None), + ]; + + for (context, input, extension, expected) in cases { + assert_eq!( + override_live_hls_extension(context, input, extension), + expected, + "case: {context:?} input_type={:?} ext={extension:?}", + input.input_type + ); + } +} + +fn provider_input_with_flag(input_type: InputType, disable_hls: bool) -> ConfigInput { + let dto = shared::model::ConfigInputDto { + name: "ts-provider".into(), + input_type, + url: "http://provider.test".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + enabled: true, + options: Some(shared::model::ConfigInputOptionsDto { + disable_hls_streaming: disable_hls, + ..shared::model::ConfigInputOptionsDto::default() + }), + ..shared::model::ConfigInputDto::default() + }; + ConfigInput::from(&dto) +} + +#[test] +fn hls_failure_response_uses_resolved_playback_extension() { + let vod = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); + let hls_vod = + create_test_vod_item("provider://strong/movie/user/pass/813564.m3u8", "m3u8", PlaylistItemType::Video); + + assert!(!is_hls_playback_request(Some(".m3u8"), &vod)); + assert!(is_hls_playback_request(None, &hls_vod)); +} + +async fn response_body_text(response: axum::response::Response) -> Result { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|err| format!("failed to read response body: {err}"))?; + String::from_utf8(body.to_vec()).map_err(|err| format!("response body is not UTF-8: {err}")) +} + +fn short_epg_target() -> ConfigTarget { + ConfigTarget { + id: 1, + enabled: true, + name: "ics-xtream".to_string(), + options: None, + sort: None, + filter: Filter::default().into(), + 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(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), + watch: None, + use_memory_cache: true, + } +} + +fn short_epg_live_item() -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: VirtualId::new(100), + provider_id: 0, + name: "Formula 1".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "Sports".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "http://example.invalid/live.ts".intern(), + epg_channel_id: Some("f1.calendar".intern()), + xtream_cluster: XtreamCluster::Live, + additional_properties: None, + item_type: PlaylistItemType::Live, + category_id: 1, + input_name: "local".intern(), + channel_no: 1, + source_ordinal: 0, + input_stream_id: "".intern(), + upstream_user_agent: None, + } +} + +#[tokio::test] +async fn xtream_short_epg_returns_imported_ics_programme_for_matching_channel_id() { + let dir = tempdir().expect("temp dir"); + let ics_path = dir.path().join("calendar.ics"); + let start = chrono::Utc::now() + chrono::Duration::days(1); + let stop = start + chrono::Duration::hours(1); + std::fs::write( + &ics_path, + format!( + concat!( + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "BEGIN:VEVENT\r\n", + "UID:f1-qualifying\r\n", + "DTSTART:{}\r\n", + "DTEND:{}\r\n", + "SUMMARY:Formula 1 Qualifying\r\n", + "DESCRIPTION:Imported from ICS\r\n", + "END:VEVENT\r\n", + "END:VCALENDAR\r\n", + ), + start.format("%Y%m%dT%H%M%SZ"), + stop.format("%Y%m%dT%H%M%SZ"), + ), + ) + .expect("write ICS fixture"); + let channel = parse_ics_file_to_channel( + &ics_path, + "f1.calendar".intern(), + Some("Formula 1".intern()), + &IcsEpgSourceConfig::default(), + ) + .await + .expect("parse ICS fixture"); + + let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; + let target = Arc::new(short_epg_target()); + let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); + std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); + let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); + epg_write_file( + &target.name, + &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, + &epg_path, + &std::collections::HashMap::, Arc>::new(), + &shared::model::EpgOutputOptions::default(), + ) + .expect("write target EPG"); + + let app_state = create_test_app_state(config); + let live_item = short_epg_live_item(); + let mut live = BPlusTree::new(); + live.insert(live_item.virtual_id.get(), live_item.clone()); + app_state + .playlists + .cache_playlist( + &target.name, + PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { + live, + vod: BPlusTree::new(), + series: BPlusTree::new(), + })), + ) + .await; + let mut id_mapping = BPlusTree::new(); + id_mapping.insert( + live_item.virtual_id, + VirtualIdRecord::new( + live_item.provider_id, + live_item.virtual_id, + PlaylistItemType::Live, + VirtualId::new(0), + UUIDType::default(), + ), + ); + app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; + + let mut user = ProxyUserCredentials::default(); + user.output_clusters = ClusterFlags::all(); + let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); + let body = response_body_text(response).await.expect("read short EPG response"); + let json: serde_json::Value = serde_json::from_str(&body).expect("parse short EPG JSON"); + let decode_listing_text = |field: &str| { + let encoded = json["epg_listings"][0][field].as_str().expect("encoded listing text"); + String::from_utf8(BASE64_STANDARD.decode(encoded).expect("decode listing text")) + .expect("decoded listing text is UTF-8") + }; + + assert_eq!(json["epg_listings"][0]["channel_id"], "f1.calendar"); + assert_eq!(decode_listing_text("title"), "Formula 1 Qualifying"); + assert_eq!(decode_listing_text("description"), "Imported from ICS"); +} + +fn m3u_catchup_item(name: &str, input_name: &str, url: &str, catchup_source: Option<&str>) -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: VirtualId::new(100), + provider_id: 0, + name: name.intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "Live".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: url.intern(), + epg_channel_id: Some("f1.calendar".intern()), + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + category_id: 1, + input_name: input_name.intern(), + channel_no: 1, + source_ordinal: 0, + input_stream_id: "100".intern(), + additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + name: name.intern(), + stream_id: 100, + tv_archive: Some(1), + tv_archive_duration: Some(7), + catchup: catchup_source.map(|src| shared::model::CatchupProperties { + mode: Some("flussonic".intern()), + source: Some(src.intern()), + ..shared::model::CatchupProperties::default() + }), + ..shared::model::LiveStreamProperties::default() + }))), + upstream_user_agent: None, + } +} + +async fn cache_xtream_test_item( + app_state: &Arc, + target: &Arc, + item: XtreamPlaylistItem, + input: Option, +) { + if let Some(input) = input { + let sources = SourcesConfig { + inputs: vec![Arc::new(input)], + sources: vec![crate::model::ConfigSource { + inputs: vec![item.input_name.clone()], + targets: vec![target.clone()], + }], + ..SourcesConfig::default() + }; + app_state.app_config.sources.store(Arc::new(sources)); + } + let mut live = BPlusTree::new(); + live.insert(item.virtual_id.get(), item.clone()); + app_state + .playlists + .cache_playlist( + &target.name, + PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { + live, + vod: BPlusTree::new(), + series: BPlusTree::new(), + })), + ) + .await; + let mut id_mapping = BPlusTree::new(); + id_mapping.insert( + item.virtual_id, + VirtualIdRecord::new( + item.provider_id, + item.virtual_id, + PlaylistItemType::Live, + VirtualId::new(0), + UUIDType::default(), + ), + ); + app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; +} + +async fn build_short_epg_app_state( + dir: &tempfile::TempDir, + item: XtreamPlaylistItem, + input: Option, +) -> (Arc, Arc) { + let ics_path = dir.path().join("calendar.ics"); + let start = chrono::Utc::now() + chrono::Duration::days(1); + let stop = start + chrono::Duration::hours(1); + std::fs::write( + &ics_path, + format!( + concat!( + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "BEGIN:VEVENT\r\n", + "UID:f1-qualifying\r\n", + "DTSTART:{}\r\n", + "DTEND:{}\r\n", + "SUMMARY:Formula 1 Qualifying\r\n", + "DESCRIPTION:Imported from ICS\r\n", + "END:VEVENT\r\n", + "END:VCALENDAR\r\n", + ), + start.format("%Y%m%dT%H%M%SZ"), + stop.format("%Y%m%dT%H%M%SZ"), + ), + ) + .expect("write ICS fixture"); + let channel = parse_ics_file_to_channel( + &ics_path, + "f1.calendar".intern(), + Some("Formula 1".intern()), + &IcsEpgSourceConfig::default(), + ) + .await + .expect("parse ICS fixture"); + + let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; + let target = Arc::new(short_epg_target()); + let xtream_storage = xtream_get_storage_path(&config, &target.name).expect("xtream storage path"); + std::fs::create_dir_all(&xtream_storage).expect("create xtream storage"); + let epg_path = xtream_get_epg_file_path_for_target(&xtream_storage); + epg_write_file( + &target.name, + &Epg { priority: 0, logo_override: false, attributes: None, children: vec![Arc::new(channel)] }, + &epg_path, + &std::collections::HashMap::, Arc>::new(), + &shared::model::EpgOutputOptions::default(), + ) + .expect("write target EPG"); + + let app_state = create_test_app_state(config); + cache_xtream_test_item(&app_state, &target, item, input).await; + + (app_state, target) +} + +#[tokio::test] +async fn xtream_short_epg_emits_has_archive_for_m3u_with_bridge_template() { + let dir = tempdir().expect("temp dir"); + let input = m3u_timeshift_input(); + let item = m3u_catchup_item( + "Formula 1", + &input.name, + "channel/index.m3u8", + Some("http://provider.example/channel/video-{utc}-{duration}.m3u8"), + ); + let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; + let mut user = ProxyUserCredentials::default(); + user.output_clusters = ClusterFlags::all(); + + let response = xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response(); + let body = response_body_text(response).await.expect("body"); + let json: serde_json::Value = serde_json::from_str(&body).expect("json"); + + assert_eq!(json["epg_listings"][0]["has_archive"], 1); +} + +#[tokio::test] +async fn xtream_short_epg_omits_has_archive_for_m3u_with_unsupported_catchup() { + let dir = tempdir().expect("temp dir"); + let input = m3u_timeshift_input(); + let item = m3u_catchup_item( + "Formula 1", + &input.name, + "channel/index.m3u8", + Some("http://provider.example/channel/${timestamp}.m3u8"), + ); + let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; + let mut user = ProxyUserCredentials::default(); + user.output_clusters = ClusterFlags::all(); + + let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) + .await + .expect("body"); + let json: serde_json::Value = serde_json::from_str(&body).expect("json"); + + assert!(json["epg_listings"][0].get("has_archive").is_none()); +} + +#[tokio::test] +async fn xtream_short_epg_emits_has_archive_for_native_xtream_input() { + let dir = tempdir().expect("temp dir"); + let mut input = m3u_timeshift_input(); + input.input_type = InputType::Xtream; + let item = m3u_catchup_item("Formula 1", &input.name, "channel/index.m3u8", None); + let (app_state, target) = build_short_epg_app_state(&dir, item, Some(input)).await; + let mut user = ProxyUserCredentials::default(); + user.output_clusters = ClusterFlags::all(); + + let body = response_body_text(xtream_get_short_epg(&app_state, &user, &target, "100", 4).await.into_response()) + .await + .expect("body"); + let json: serde_json::Value = serde_json::from_str(&body).expect("json"); + + assert_eq!(json["epg_listings"][0]["has_archive"], 1); +} + +#[tokio::test] +async fn empty_stream_info_response_uses_vod_object_and_list_shapes() -> Result<(), String> { + assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Video)).await?, "{}"); + assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Live)).await?, "[]"); + assert_eq!(response_body_text(empty_stream_info_response(XtreamCluster::Series)).await?, "[]"); + Ok(()) +} + +fn create_test_vod_item(url: &str, container_extension: &str, item_type: PlaylistItemType) -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: VirtualId::new(176_141), + provider_id: 813_563, + name: "Test".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: Arc::::from(url), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + name: "Test".intern(), + category_id: 0, + stream_id: 813_563, + stream_icon: "".intern(), + direct_source: "".intern(), + custom_sid: None, + added: "".intern(), + container_extension: container_extension.intern(), + rating: None, + rating_5based: None, + stream_type: Some("movie".intern()), + trailer: None, + tmdb: None, + is_adult: 0, + details: None, + }))), + item_type, + category_id: 0, + input_name: "strong".intern(), + channel_no: 0, + source_ordinal: 0, + input_stream_id: "813563".intern(), + upstream_user_agent: None, + } +} + +#[test] +fn post_query_only_request_prefers_query_when_form_is_missing() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + action: String::from("get_live_streams"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.action, "get_live_streams"); +} + +#[test] +fn post_request_prefers_query_over_form() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + action: String::from("query-action"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + action: String::from("form-action"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.action, "query-action"); +} + +#[test] +fn timeshift_query_request_prefers_query_when_form_is_missing() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.stream, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); +} + +#[test] +fn timeshift_query_request_prefers_query_over_form() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + stream: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + stream: String::from("99"), + duration: String::from("10"), + start: String::from("form-start"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.stream, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); +} + +#[test] +fn timeshift_path_request_prefers_query_when_form_is_missing() { + let timeshift_request = XtreamApiTimeShiftRequest { + username: String::new(), + password: String::new(), + duration: String::new(), + start: String::new(), + stream_id: String::new(), + }; + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream_id: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let query_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + let path_req = UserApiRequest { + username: timeshift_request.username, + password: timeshift_request.password, + duration: timeshift_request.duration, + start: timeshift_request.start, + stream_id: timeshift_request.stream_id, + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.stream_id, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); +} + +#[test] +fn timeshift_path_request_prefers_path_over_query_and_form() { + let timeshift_request = XtreamApiTimeShiftRequest { + username: String::from("path-user"), + password: String::from("path-pass"), + duration: String::from("120"), + start: String::from("path-start"), + stream_id: String::from("7"), + }; + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream_id: String::from("42"), + duration: String::from("60"), + start: String::from("query-start"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + password: String::from("form-pass"), + stream_id: String::from("99"), + duration: String::from("10"), + start: String::from("form-start"), + ..UserApiRequest::default() + }; + + let query_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + let path_req = UserApiRequest { + username: timeshift_request.username, + password: timeshift_request.password, + duration: timeshift_request.duration, + start: timeshift_request.start, + stream_id: timeshift_request.stream_id, + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); + + assert_eq!(api_req.username, "path-user"); + assert_eq!(api_req.password, "path-pass"); + assert_eq!(api_req.stream_id, "7"); + assert_eq!(api_req.duration, "120"); + assert_eq!(api_req.start, "path-start"); +} + +#[test] +fn non_live_playback_extension_prefers_canonical_item_extension_over_client_override() { + let pli = create_test_vod_item("provider://strong/movie/user/pass/813563.mp4", "mp4", PlaylistItemType::Video); + + assert_eq!(resolve_xtream_playback_extension(Some(".mkv"), &pli).as_deref(), Some(".mp4")); +} + +#[test] +fn media_server_xtream_playback_uses_internal_stream_ref_even_with_direct_pms_url_credentials() { + let input = ConfigInput { + input_type: InputType::Plex, + url: "http://pms-user:pms-pass@pms.example.invalid:32400".to_string(), + ..ConfigInput::default() + }; + let fallback = + Arc::::from("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted%2Ffile.mkv"); + + let resolved = get_xtream_player_api_stream_url(&input, ApiStreamContext::Movie, "813563.mkv", &fallback) + .expect("media-server fallback should be preserved"); + + assert_eq!(resolved, fallback); +} + +#[test] +fn live_playback_extension_keeps_client_requested_extension() { + let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); + pli.xtream_cluster = XtreamCluster::Live; + + assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); +} + +#[test] +fn catchup_playback_extension_preserves_requested_adaptive_extension_for_underlying_live_item() { + let mut pli = create_test_vod_item("provider://strong/live/user/pass/813563.ts", "ts", PlaylistItemType::Live); + pli.xtream_cluster = XtreamCluster::Live; + + assert_eq!(resolve_xtream_playback_extension(Some(".m3u8"), &pli).as_deref(), Some(".m3u8")); +} + +fn m3u_timeshift_input() -> ConfigInput { + ConfigInput { + id: 0, + name: "m3u-flussonic".intern(), + input_type: InputType::M3u, + url: "http://provider.example".to_string(), + username: Some("alice".to_string()), + password: Some("secret".to_string()), + ..ConfigInput::default() + } +} + +fn m3u_timeshift_item() -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: VirtualId::new(100), + provider_id: 0, + name: "Live TV".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "Live".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "channel/index.m3u8".intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Live, + additional_properties: Some(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + name: "Live TV".intern(), + stream_id: 100, + tv_archive: Some(1), + tv_archive_duration: Some(7), + catchup: Some(shared::model::CatchupProperties { + mode: Some("flussonic".intern()), + source: Some("http://provider.example/channel/video-{utc}-{duration}.m3u8".intern()), + ..shared::model::CatchupProperties::default() + }), + ..shared::model::LiveStreamProperties::default() + }))), + item_type: PlaylistItemType::Live, + category_id: 1, + input_name: "m3u-flussonic".intern(), + channel_no: 1, + source_ordinal: 0, + input_stream_id: "100".intern(), + upstream_user_agent: None, + } +} + +#[test] +fn m3u_timeshift_resolver_returns_resolved_url_for_flussonic_template() { + let input = m3u_timeshift_input(); + let item = m3u_timeshift_item(); + + let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") + .expect("resolver ok") + .expect("M3U input handled by bridge"); + + assert_eq!(resolved.url, "http://provider.example/channel/video-1704067200-3600.m3u8"); + assert!(!resolved.discriminator.is_empty()); +} + +#[test] +fn m3u_timeshift_resolver_distinguishes_time_windows() { + let input = m3u_timeshift_input(); + let item = m3u_timeshift_item(); + + let first = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect("first ok").expect("some"); + let second = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:01-00").expect("second ok").expect("some"); + + assert_ne!(first.url, second.url); + assert_ne!(first.discriminator, second.discriminator); + let fp = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()); + assert_ne!( + crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &first.discriminator), + crate::api::api_utils::create_m3u_catchup_session_key(&fp, "alice", 100, &second.discriminator), + ); +} + +#[test] +fn m3u_timeshift_resolver_rejects_missing_catchup_metadata() { + let mut item = m3u_timeshift_item(); + if let Some(StreamProperties::Live(live)) = item.additional_properties.as_mut() { + live.catchup = None; + } + let input = m3u_timeshift_input(); + + let err = + resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect_err("missing catchup must error"); + assert_eq!(err.kind(), shared::error::ErrorKind::ApiXtream); +} + +#[test] +fn m3u_timeshift_resolver_returns_none_for_non_m3u_input() { + let mut input = m3u_timeshift_input(); + input.input_type = InputType::Xtream; + let item = m3u_timeshift_item(); + + let resolved = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00") + .expect("non-M3U inputs are skipped, not errored"); + assert!(resolved.is_none(), "non-M3U input must be skipped by helper"); +} + +#[test] +fn m3u_timeshift_stream_url_uses_resolved_archive_for_credential_bearing_input() { + let input = m3u_timeshift_input(); + let resolved_url: Arc = Arc::from("http://provider.example/channel/video-1704067200-3600.m3u8"); + + let url = + get_xtream_player_api_stream_url(&input, ApiStreamContext::Timeshift, "60/2024-01-01:00-00", &resolved_url) + .expect("resolved archive URL must win for M3U timeshift"); + + assert_eq!(url, resolved_url); + assert!(!url.contains("alice")); + assert!(!url.contains("secret")); +} + +#[tokio::test] +async fn expired_user_is_rejected_before_m3u_timeshift_is_resolved() -> Result<(), String> { + let dir = tempdir().map_err(|err| err.to_string())?; + let input = m3u_timeshift_input(); + let item = m3u_timeshift_item(); + let config = Config { + storage_dir: dir.path().to_string_lossy().into_owned(), + user_access_control: true, + ..Config::default() + }; + let app_state = create_test_app_state(config); + let target = Arc::new(short_epg_target()); + cache_xtream_test_item(&app_state, &target, item, Some(input)).await; + + let mut user = ProxyUserCredentials::default(); + user.username = "expired".to_string(); + user.status = Some(ProxyUserStatus::Expired); + user.output_clusters = ClusterFlags::all(); + let user = Arc::new(user); + let addr = "127.0.0.1:0".parse().map_err(|err: std::net::AddrParseError| err.to_string())?; + let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr); + + let response = xtream_player_api_stream( + &fingerprint, + &HeaderMap::new(), + &app_state, + &UserApiRequest::default(), + ApiStreamRequest::from(ApiStreamContext::Timeshift, "expired", "", "100.ts", "invalid"), + Some((user, target)), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY); + Ok(()) +} + +#[tokio::test] +async fn xtream_token_stream_rejects_m3u_timeshift_with_bad_request() { + use crate::auth::create_access_token; + + let dir = tempdir().expect("temp dir"); + let config = Config { storage_dir: dir.path().to_string_lossy().into_owned(), ..Config::default() }; + let app_state = create_test_app_state(config); + let target = Arc::new(short_epg_target()); + let input = m3u_timeshift_input(); + let item = m3u_timeshift_item(); + cache_xtream_test_item(&app_state, &target, item, Some(input)).await; + + let token = create_access_token(&app_state.app_config.access_token_secret, 60, crate::auth::scope::INTERNAL_PLAYER); + + let response = xtream_player_api_stream_with_token( + &Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()), + &HeaderMap::new(), + &app_state, + target.id, + ApiStreamRequest::from_access_token(ApiStreamContext::Timeshift, &token, "100.ts", ""), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} diff --git a/backend/app/src/api/endpoints/xtream_url.rs b/backend/app/src/api/endpoints/xtream_url.rs index ac6cb5666..9af735ca3 100644 --- a/backend/app/src/api/endpoints/xtream_url.rs +++ b/backend/app/src/api/endpoints/xtream_url.rs @@ -1,28 +1,11 @@ //! Shared URL-building helpers for the xtream and HLS code paths. //! -//! Three symbols (`ApiStreamContext`, `get_query_path`, -//! `get_xtream_player_api_stream_url`) historically live in `xtream_api.rs` -//! but both `xtream_api` and `hls_api` need them. Importing them directly from -//! `xtream_api` creates a visible cross-import between two sibling endpoint -//! files, which is the symptom of a missing service layer. Until the full -//! `services::hls::playback` extraction lands (see ARCH-1 roadmap below), -//! route the cross-import through this re-export module so: +//! `ApiStreamContext`, `get_query_path`, and +//! `get_xtream_player_api_stream_url` live in `xtream_api.rs`, while both the +//! Xtream and HLS handlers use them. This module keeps that dependency one-way +//! and gives callers a single stable import boundary: //! //! - `hls_api` no longer depends on a sibling endpoint file directly. //! - The cross-dependency is one-way and visible at a single import site. -//! - Future extraction only has to move this file's contents, not chase -//! cross-imports across the `hls_api` surface. -//! -//! # ARCH-1 roadmap (deferred) -//! -//! The full `ARCH-1` fix extracts a `services::hls::playback` module that owns -//! the auth, admission, session lookup, lease, cache serve, and fallback -//! pipeline. `hls_api.rs` then becomes a thin Axum handler layer. That -//! extraction touches about 50 imports and 30 functions; it is intentionally -//! deferred from this commit because the change requires concurrent test -//! re-orchestration for every state-machine branch in the cache, and any -//! partial move is more dangerous than a single atomic commit. This -//! re-export module is the smallest step that unblocks the dependency -//! direction without that risk. pub(in crate::api) use super::xtream_api::{get_query_path, get_xtream_player_api_stream_url, ApiStreamContext}; diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index 7ffc6dd50..b74d61cba 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -443,6 +443,7 @@ async fn create_shared_data( shared_stream_manager, hls_proxy, hls_provisioning: Arc::new(HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, @@ -1207,6 +1208,7 @@ mod tests { shared_stream_manager, hls_proxy: Arc::new(HlsProxyManager::new()), hls_provisioning: Arc::new(HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, diff --git a/backend/app/src/api/model/app_state.rs b/backend/app/src/api/model/app_state.rs index b2ce12285..e49325b4d 100644 --- a/backend/app/src/api/model/app_state.rs +++ b/backend/app/src/api/model/app_state.rs @@ -4,7 +4,8 @@ use crate::{ model::{ load_target_into_memory_cache, recording_rule_scheduler::spawn_recording_rule_scheduler, ActiveProviderManager, ActiveUserManager, ConnectionManager, DownloadQueue, EventManager, - HlsProvisioningState, PlaylistStorage, PlaylistStorageState, SharedStreamManager, UpdateGuard, + HlsProvisioningState, PlaylistStorage, PlaylistStorageState, SharedStreamManager, + StalkerResolveCoordinator, UpdateGuard, }, tasks::{exec_config_watch, exec_scheduler}, }, @@ -33,10 +34,7 @@ use std::{ sync::{atomic::AtomicI8, Arc}, time::Duration, }; -use tokio::{ - sync::{mpsc, RwLock}, - task, -}; +use tokio::sync::{mpsc, RwLock}; use tokio_util::sync::CancellationToken; use tuliprox_hls::api::HlsProxyManager; use tuliprox_metadata::manager::MetadataUpdateManager; @@ -362,12 +360,15 @@ pub fn create_cache(config: &Config) -> Option>> { let cache = Arc::new(RwLock::new(res_cache)); let cache_scanner = Arc::clone(&cache); tokio::spawn(async move { - let scan_result = { - let mut cache = cache_scanner.write().await; - task::block_in_place(|| cache.scan()) - }; - if let Err(err) = scan_result { - error!("Failed to scan cache {err}"); + let scan_result = tokio::task::spawn_blocking(move || { + let mut cache = cache_scanner.blocking_write(); + cache.scan() + }) + .await; + match scan_result { + Ok(Err(err)) => error!("Failed to scan cache {err}"), + Err(err) => error!("Failed to join cache scan task: {err}"), + Ok(Ok(())) => {} } }); return Some(cache); @@ -432,6 +433,7 @@ pub struct AppState { pub shared_stream_manager: Arc, pub hls_proxy: Arc, pub hls_provisioning: Arc, + pub(crate) stalker_resolve_coordinator: Arc, pub active_users: Arc, pub active_provider: Arc, pub connection_manager: Arc, @@ -520,6 +522,7 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { shared_stream_manager, hls_proxy: Arc::new(HlsProxyManager::new()), hls_provisioning: Arc::new(HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, @@ -799,13 +802,7 @@ fn schedules_changed(a: &[ScheduleConfig], b: &[ScheduleConfig]) -> bool { } fn hdhomerun_changed(a: &HdHomeRunConfig, b: &HdHomeRunConfig) -> bool { - if a.flags != b.flags { - return true; - } - if !small_vecs_equal_unordered(a.devices.as_ref(), b.devices.as_ref()) { - return true; - } - false + a.flags != b.flags || !small_vecs_equal_unordered(a.devices.as_ref(), b.devices.as_ref()) } fn string_changed(a: &str, b: &str) -> bool { a != b } @@ -825,6 +822,21 @@ fn providers_changed(a: &[Arc], b: &[Arc]) -> bo false } +fn stream_history_tuple(cfg: Option<&crate::model::StreamHistoryConfig>) -> Option<(bool, &str, u16, usize)> { + cfg.map(|history| { + ( + history.stream_history_enabled, + history.stream_history_directory.as_str(), + history.stream_history_retention_days, + history.stream_history_batch_size, + ) + }) +} + +fn qos_tuple(cfg: Option<&crate::model::QosAggregationConfig>) -> Option<(bool, u64, u64)> { + cfg.map(|qos| (qos.enabled, qos.interval_secs, qos.compaction_interval_secs)) +} + fn qos_aggregation_changed(old_config: &Config, new_config: &Config) -> bool { let old_reverse_proxy = old_config.reverse_proxy.as_ref(); let new_reverse_proxy = new_config.reverse_proxy.as_ref(); @@ -834,20 +846,6 @@ fn qos_aggregation_changed(old_config: &Config, new_config: &Config) -> bool { let old_qos = old_reverse_proxy.and_then(|rp| rp.qos_aggregation.as_ref()); let new_qos = new_reverse_proxy.and_then(|rp| rp.qos_aggregation.as_ref()); - let stream_history_tuple = |cfg: Option<&crate::model::StreamHistoryConfig>| { - cfg.map(|history| { - ( - history.stream_history_enabled, - history.stream_history_directory.clone(), - history.stream_history_retention_days, - history.stream_history_batch_size, - ) - }) - }; - let qos_tuple = |cfg: Option<&crate::model::QosAggregationConfig>| { - cfg.map(|qos| (qos.enabled, qos.interval_secs, qos.compaction_interval_secs)) - }; - stream_history_tuple(old_stream_history) != stream_history_tuple(new_stream_history) || qos_tuple(old_qos) != qos_tuple(new_qos) } diff --git a/backend/app/src/api/model/mod.rs b/backend/app/src/api/model/mod.rs index a32900ce5..fb462dbe6 100644 --- a/backend/app/src/api/model/mod.rs +++ b/backend/app/src/api/model/mod.rs @@ -2,6 +2,7 @@ mod app_state; mod app_state_view; mod hls_provisioning; mod proxy; +mod stalker_resolve_coordinator; mod streams; #[cfg(test)] @@ -14,8 +15,8 @@ pub(in crate::api) use self::hls_provisioning::{ parse_hls_panel_provisioning_segment_route_name, start_hls_panel_provisioning_once, try_hls_panel_provisioning_manifest_response, HlsPanelProvisioningRedirectPaths, HlsProvisioningStatus, }; -pub(crate) use self::streams::*; pub use self::{app_state::*, app_state_view::*, hls_provisioning::HlsProvisioningState, proxy::*}; +pub(crate) use self::{stalker_resolve_coordinator::StalkerResolveCoordinator, streams::*}; // Provider value types moved to `model`; re-exported so `api` keeps its names. pub use crate::model::provider::*; // Update semaphores moved to `model`; re-exported so `api` keeps its names. diff --git a/backend/app/src/api/model/stalker_resolve_coordinator.rs b/backend/app/src/api/model/stalker_resolve_coordinator.rs new file mode 100644 index 000000000..7ee0bbaa3 --- /dev/null +++ b/backend/app/src/api/model/stalker_resolve_coordinator.rs @@ -0,0 +1,58 @@ +use std::{ + collections::HashMap, + sync::{Arc, Weak}, +}; +use tokio::sync::Mutex; + +type StalkerResolveGuardKey = (u16, u32); + +/// Instance-scoped serialization for Stalker portal URL resolution. +/// +/// The map keeps only weak references so cancellation cannot retain an unused +/// per-provider lock. Expired entries are pruned opportunistically whenever a +/// new guard is requested. +#[derive(Default)] +pub(crate) struct StalkerResolveCoordinator { + guards: Mutex>>>, +} + +impl StalkerResolveCoordinator { + pub(crate) async fn guard_for(&self, input_id: u16, provider_id: u32) -> Arc> { + let key = (input_id, provider_id); + let mut guards = self.guards.lock().await; + guards.retain(|_, guard| guard.strong_count() > 0); + if let Some(guard) = guards.get(&key).and_then(Weak::upgrade) { + return guard; + } + let guard = Arc::new(Mutex::new(())); + guards.insert(key, Arc::downgrade(&guard)); + guard + } +} + +#[cfg(test)] +mod tests { + use super::StalkerResolveCoordinator; + use std::sync::Arc; + + #[tokio::test] + async fn same_instance_reuses_only_live_guard_for_the_same_key() { + let coordinator = StalkerResolveCoordinator::default(); + let first = coordinator.guard_for(1, 2).await; + let concurrent = coordinator.guard_for(1, 2).await; + assert!(Arc::ptr_eq(&first, &concurrent)); + + drop(first); + drop(concurrent); + let replacement = coordinator.guard_for(1, 2).await; + assert_eq!(Arc::strong_count(&replacement), 1); + } + + #[tokio::test] + async fn separate_instances_do_not_share_guards() { + let first = StalkerResolveCoordinator::default().guard_for(1, 2).await; + let second = StalkerResolveCoordinator::default().guard_for(1, 2).await; + + assert!(!Arc::ptr_eq(&first, &second)); + } +} diff --git a/backend/app/src/api/model/streams/active_client_stream.rs b/backend/app/src/api/model/streams/active_client_stream.rs index 1bd22dfe0..c2c661e37 100644 --- a/backend/app/src/api/model/streams/active_client_stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream.rs @@ -639,7 +639,7 @@ impl Stream for ActiveClientStream { None => StreamMode::Inner, }; - // 4. Dispatch based on current streaming phase + // Dispatch based on the current streaming phase. match mode { // Grace period: hold_stream=true, waiting for grace task to resolve StreamMode::GracePending => { @@ -1507,6 +1507,7 @@ mod tests { shared_stream_manager, hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, @@ -1585,6 +1586,7 @@ mod tests { shared_stream_manager, hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()), + stalker_resolve_coordinator: Arc::default(), active_users, active_provider, connection_manager, diff --git a/backend/app/src/api/panel_api.rs b/backend/app/src/api/panel_api/mod.rs similarity index 89% rename from backend/app/src/api/panel_api.rs rename to backend/app/src/api/panel_api/mod.rs index 51e818995..e1a1edddd 100644 --- a/backend/app/src/api/panel_api.rs +++ b/backend/app/src/api/panel_api/mod.rs @@ -2648,7 +2648,7 @@ async fn sync_panel_api_for_input_on_boot( ); } - // Plan and execute alias refresh after the root operation (avoids over-provisioning). + // Refresh aliases after the root operation to avoid over-provisioning. let now = effective_now; let offset_deadline = now.saturating_add(offset_secs); let root_valid = root_counts_towards_pool_at(&accounts, &input.name, now); @@ -3722,418 +3722,4 @@ pub fn create_panel_api_provisioning_stream_details( } #[cfg(test)] -mod tests { - use super::{ - build_panel_api_probe_targets, build_user_api_account_info_input_source, - panel_api_retry_after_from_header_value, panel_api_retryable_status, resolve_batch_alias_path, - PanelApiProbeTarget, PANEL_API_DEFAULT_RETRY_AFTER_SECS, PANEL_API_MAX_RETRY_AFTER_SECS, - }; - use crate::{ - api::source_yml_patch::{apply_sources_yml_patches, resolve_provisioned_account_base_url, SourcesYmlPatch}, - model::{ConfigInput, ConfigProvider}, - repository::AliasExpDateSortOrder, - }; - use axum::http::StatusCode; - use shared::model::{ - ConfigInputAliasDto, ConfigInputDto, ConfigProviderDto, InputType, ProviderUrlSelectionPolicy, SourcesConfigDto, - }; - use std::{sync::Arc, time::Duration}; - use url::Url; - - fn source_alias(name: &str, exp_date: Option) -> ConfigInputAliasDto { - ConfigInputAliasDto { - id: 0, - name: Arc::from(name), - url: "provider://tivione".to_string(), - username: Some(format!("{name}-user")), - password: Some(format!("{name}-pass")), - priority: 0, - max_connections: 1, - exp_date, - enabled: true, - stalker: None, - } - } - - #[test] - fn batch_alias_storage_never_falls_back_to_source_yml() { - assert!(resolve_batch_alias_path(None).expect("non-batch input").is_none()); - assert!(resolve_batch_alias_path(Some("provider://not-a-csv")).is_err()); - } - - fn source_doc_with_aliases(aliases: Vec) -> SourcesConfigDto { - SourcesConfigDto { - inputs: vec![ConfigInputDto { - name: Arc::from("cdn-dev"), - input_type: InputType::Xtream, - url: "provider://tivione".to_string(), - username: Some("root-user".to_string()), - password: Some("root-pass".to_string()), - aliases: Some(aliases), - ..ConfigInputDto::default() - }], - ..SourcesConfigDto::default() - } - } - - #[test] - fn sources_yml_add_alias_appends_without_overwriting_existing_alias() { - let mut doc = source_doc_with_aliases(vec![source_alias("cdn-dev-old", Some(10))]); - doc.inputs[0].aliases.as_mut().expect("aliases")[0].id = 2; - - let changed = apply_sources_yml_patches( - &mut doc, - &[ - SourcesYmlPatch::AddAlias { - input_name: Arc::from("cdn-dev"), - alias_name: Arc::from("cdn-dev-new"), - base_url: "provider://tivione".to_string(), - username: "new-user".to_string(), - password: "new-pass".to_string(), - exp_date: Some(20), - }, - SourcesYmlPatch::SortAliases { - input_name: Arc::from("cdn-dev"), - order: AliasExpDateSortOrder::NewestFirst, - }, - ], - ) - .expect("patches apply"); - - assert!(changed); - let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); - assert_eq!(aliases.len(), 2); - assert_eq!(aliases[0].name.as_ref(), "cdn-dev-new"); - assert_eq!(aliases[0].id, 3); - assert_eq!(aliases[1].name.as_ref(), "cdn-dev-old"); - assert_eq!(aliases[1].id, 2); - } - - #[test] - fn sources_yml_sort_aliases_oldest_first_is_available_for_maintenance_paths() { - let mut doc = source_doc_with_aliases(vec![ - source_alias("cdn-dev-newest", Some(30)), - source_alias("cdn-dev-oldest", Some(10)), - source_alias("cdn-dev-missing-exp", None), - ]); - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::SortAliases { - input_name: Arc::from("cdn-dev"), - order: AliasExpDateSortOrder::OldestFirst, - }], - ) - .expect("patches apply"); - - assert!(changed); - let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); - assert_eq!(aliases[0].name.as_ref(), "cdn-dev-oldest"); - assert_eq!(aliases[1].name.as_ref(), "cdn-dev-newest"); - assert_eq!(aliases[2].name.as_ref(), "cdn-dev-missing-exp"); - } - - #[test] - fn sources_yml_update_exp_date_keeps_legacy_root_refresh_semantics() { - let mut doc = source_doc_with_aliases(Vec::new()); - doc.inputs[0].exp_date = Some(20); - doc.inputs[0].enabled = false; - doc.inputs[0].max_connections = 0; - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::UpdatePanelAccountExpiry { - input_name: Arc::from("cdn-dev"), - account_name: Arc::from("cdn-dev"), - exp_date: 20, - }], - ) - .expect("patches apply"); - - assert!(changed); - assert_eq!(doc.inputs[0].exp_date, Some(20)); - assert!(doc.inputs[0].enabled); - assert_eq!(doc.inputs[0].max_connections, 1); - } - - #[test] - fn sources_yml_update_exp_date_keeps_legacy_alias_refresh_semantics() { - let mut doc = source_doc_with_aliases(vec![source_alias("cdn-dev-old", Some(20))]); - doc.inputs[0].aliases.as_mut().expect("aliases")[0].max_connections = 0; - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::UpdatePanelAccountExpiry { - input_name: Arc::from("cdn-dev"), - account_name: Arc::from("cdn-dev-old"), - exp_date: 20, - }], - ) - .expect("patches apply"); - - assert!(changed); - let alias = &doc.inputs[0].aliases.as_ref().expect("aliases")[0]; - assert_eq!(alias.exp_date, Some(20)); - assert_eq!(alias.max_connections, 1); - } - - #[test] - fn sources_yml_update_root_credentials_updates_root_directly() { - let mut doc = source_doc_with_aliases(Vec::new()); - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::UpdateRootCredentials { - input_name: Arc::from("cdn-dev"), - username: "new-root".to_string(), - password: "new-pass".to_string(), - exp_date: Some(42), - }], - ) - .expect("patches apply"); - - assert!(changed); - assert_eq!(doc.inputs[0].username.as_deref(), Some("new-root")); - assert_eq!(doc.inputs[0].password.as_deref(), Some("new-pass")); - assert_eq!(doc.inputs[0].exp_date, Some(42)); - assert!(doc.inputs[0].enabled); - assert_eq!(doc.inputs[0].max_connections, 1); - } - - #[test] - fn sources_yml_persist_provisioned_account_adds_alias_when_current_root_is_valid() { - let mut doc = source_doc_with_aliases(Vec::new()); - doc.inputs[0].username = Some("current-root".to_string()); - doc.inputs[0].password = Some("current-pass".to_string()); - doc.inputs[0].exp_date = Some(i64::try_from(jsonwebtoken::get_current_timestamp()).expect("timestamp") + 3600); - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::PersistProvisionedAccount { - input_name: Arc::from("cdn-dev"), - username: "new-root".to_string(), - password: "new-pass".to_string(), - exp_date: Some(42), - }], - ) - .expect("patches apply"); - - assert!(changed); - assert_eq!(doc.inputs[0].username.as_deref(), Some("current-root")); - assert_eq!(doc.inputs[0].password.as_deref(), Some("current-pass")); - assert_ne!(doc.inputs[0].exp_date, Some(42)); - - let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); - assert_eq!(aliases.len(), 1); - assert_eq!(aliases[0].name.as_ref(), "cdn-dev-new-root"); - assert_eq!(aliases[0].username.as_deref(), Some("new-root")); - assert_eq!(aliases[0].password.as_deref(), Some("new-pass")); - assert_eq!(aliases[0].exp_date, Some(42)); - } - - #[test] - fn sources_yml_persist_provisioned_account_replaces_root_when_current_root_is_expired() { - let mut doc = source_doc_with_aliases(Vec::new()); - doc.inputs[0].username = Some("current-root".to_string()); - doc.inputs[0].password = Some("current-pass".to_string()); - doc.inputs[0].exp_date = Some(i64::try_from(jsonwebtoken::get_current_timestamp()).expect("timestamp") - 1); - - let changed = apply_sources_yml_patches( - &mut doc, - &[SourcesYmlPatch::PersistProvisionedAccount { - input_name: Arc::from("cdn-dev"), - username: "new-root".to_string(), - password: "new-pass".to_string(), - exp_date: Some(42), - }], - ) - .expect("patches apply"); - - assert!(changed); - assert_eq!(doc.inputs[0].username.as_deref(), Some("new-root")); - assert_eq!(doc.inputs[0].password.as_deref(), Some("new-pass")); - assert_eq!(doc.inputs[0].exp_date, Some(42)); - assert!(doc.inputs[0].aliases.as_ref().is_none_or(Vec::is_empty)); - } - - #[test] - fn panel_api_retryable_status_covers_rate_limit_and_temporary_failures() { - assert!(panel_api_retryable_status(StatusCode::TOO_MANY_REQUESTS)); - assert!(panel_api_retryable_status(StatusCode::REQUEST_TIMEOUT)); - assert!(panel_api_retryable_status(StatusCode::TOO_EARLY)); - assert!(panel_api_retryable_status(StatusCode::BAD_GATEWAY)); - assert!(!panel_api_retryable_status(StatusCode::BAD_REQUEST)); - assert!(!panel_api_retryable_status(StatusCode::UNAUTHORIZED)); - assert!(!panel_api_retryable_status(StatusCode::NOT_FOUND)); - } - - #[test] - fn panel_api_retry_after_header_is_short_and_bounded() { - assert_eq!(panel_api_retry_after_from_header_value("2"), Some(Duration::from_secs(2))); - assert_eq!( - panel_api_retry_after_from_header_value("0"), - Some(Duration::from_secs(PANEL_API_DEFAULT_RETRY_AFTER_SECS)) - ); - assert_eq!( - panel_api_retry_after_from_header_value("600"), - Some(Duration::from_secs(PANEL_API_MAX_RETRY_AFTER_SECS)) - ); - assert_eq!(panel_api_retry_after_from_header_value("not-a-delay"), None); - } - - #[test] - fn resolve_base_url_keeps_provider_scheme_for_provisioned_accounts() { - let result = resolve_provisioned_account_base_url( - "provider://demo-provider/live", - Some("http://panel.example.com:8080/get.php?username=new&password=new"), - "new", - "secret", - ); - - assert_eq!(result, "provider://demo-provider/live"); - } - - #[test] - fn resolve_base_url_updates_provider_query_credentials_when_present() { - let result = resolve_provisioned_account_base_url( - "provider://demo-provider/live?foo=bar&username=old&password=oldpw", - Some("http://panel.example.com:8080/get.php?username=new&password=new"), - "new-user", - "new-pass", - ); - - let parsed = Url::parse(result.as_str()).expect("expected valid provider url"); - let pairs: Vec<(String, String)> = parsed.query_pairs().map(|(k, v)| (k.to_string(), v.to_string())).collect(); - assert!(pairs.contains(&("foo".to_string(), "bar".to_string()))); - assert!(pairs.contains(&("username".to_string(), "new-user".to_string()))); - assert!(pairs.contains(&("password".to_string(), "new-pass".to_string()))); - } - - #[test] - fn resolve_base_url_uses_panel_response_origin_for_http_inputs() { - let result = resolve_provisioned_account_base_url( - "http://input.example.org/path?x=1", - Some("http://panel.example.com:8080/get.php?username=new&password=new"), - "new", - "secret", - ); - - assert_eq!(result, "http://panel.example.com:8080"); - } - - #[test] - fn resolve_base_url_falls_back_when_panel_response_is_literal_null() { - let result = - resolve_provisioned_account_base_url("http://input.example.org/path?x=1", Some("null"), "new", "secret"); - - assert_eq!(result, "http://input.example.org/path?x=1"); - } - - #[test] - fn resolve_base_url_avoids_null_origin_for_non_special_schemes() { - let result = resolve_provisioned_account_base_url( - "http://input.example.org/path?x=1", - Some("custom-scheme://panel.example.com/path?username=new&password=new"), - "new", - "secret", - ); - - assert_eq!(result, "custom-scheme://panel.example.com/path?username=new&password=new"); - } - - #[test] - fn panel_api_probe_targets_preserve_provider_failover_context() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "tivione".into(), - urls: vec!["http://line-a.example.test".into(), "http://line-b.example.test".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input = ConfigInput { - name: Arc::from("cdn-dev"), - url: "provider://tivione".to_string(), - provider_configs: Some(vec![Arc::clone(&provider)]), - ..ConfigInput::default() - }; - - let targets = build_panel_api_probe_targets(&input, "probe-user", "probe-pass"); - - assert_eq!(targets.len(), 4); - let PanelApiProbeTarget::PlayerApi { action, input_source } = &targets[0]; - assert_eq!(*action, "client_info"); - assert_eq!( - input_source.provider.as_ref().expect("provider context should be preserved").name.as_ref(), - "tivione" - ); - assert!(input_source.url.starts_with("provider://tivione/player_api.php?")); - assert!(input_source.url.contains("username=probe-user")); - assert!(input_source.url.contains("password=probe-pass")); - assert!(input_source.url.contains("action=client_info")); - } - - #[test] - fn panel_api_probe_targets_keep_plain_http_without_provider_context() { - let input = ConfigInput { - name: Arc::from("plain"), - url: "http://origin.example.test/some/path?ignored=1".to_string(), - ..ConfigInput::default() - }; - - let targets = build_panel_api_probe_targets(&input, "probe-user", "probe-pass"); - - assert_eq!(targets.len(), 4); - let PanelApiProbeTarget::PlayerApi { action, input_source } = &targets[0]; - assert_eq!(*action, "client_info"); - assert!(input_source.provider.is_none()); - assert!(input_source.url.starts_with("http://origin.example.test/player_api.php?")); - assert!(input_source.url.contains("username=probe-user")); - assert!(input_source.url.contains("password=probe-pass")); - assert!(input_source.url.contains("action=client_info")); - } - - #[test] - fn user_api_account_info_preserves_provider_failover_context() { - let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { - name: "tivione".into(), - urls: vec!["http://line-a.example.test".into(), "http://line-b.example.test".into()], - provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, - dns: None, - })); - let input = ConfigInput { - name: Arc::from("cdn-dev"), - url: "provider://tivione".to_string(), - provider_configs: Some(vec![Arc::clone(&provider)]), - ..ConfigInput::default() - }; - - let input_source = build_user_api_account_info_input_source(&input, "root-user", "root-pass") - .expect("expected provider account_info input source"); - - assert_eq!( - input_source.provider.as_ref().expect("provider context should be preserved").name.as_ref(), - "tivione" - ); - assert!(input_source.url.starts_with("provider://tivione/player_api.php?")); - assert!(input_source.url.contains("username=root-user")); - assert!(input_source.url.contains("password=root-pass")); - assert!(input_source.url.contains("action=account_info")); - } - - #[test] - fn user_api_account_info_keeps_plain_http_without_provider_context() { - let input = ConfigInput { - name: Arc::from("plain"), - url: "http://origin.example.test/some/path?ignored=1".to_string(), - ..ConfigInput::default() - }; - - let input_source = build_user_api_account_info_input_source(&input, "root-user", "root-pass") - .expect("expected plain account_info input source"); - - assert!(input_source.provider.is_none()); - assert!(input_source.url.starts_with("http://origin.example.test/player_api.php?")); - assert!(input_source.url.contains("username=root-user")); - assert!(input_source.url.contains("password=root-pass")); - assert!(input_source.url.contains("action=account_info")); - } -} +mod tests; diff --git a/backend/app/src/api/panel_api/tests.rs b/backend/app/src/api/panel_api/tests.rs new file mode 100644 index 000000000..222d1b901 --- /dev/null +++ b/backend/app/src/api/panel_api/tests.rs @@ -0,0 +1,404 @@ +use super::{ + build_panel_api_probe_targets, build_user_api_account_info_input_source, panel_api_retry_after_from_header_value, + panel_api_retryable_status, resolve_batch_alias_path, PanelApiProbeTarget, PANEL_API_DEFAULT_RETRY_AFTER_SECS, + PANEL_API_MAX_RETRY_AFTER_SECS, +}; +use crate::{ + api::source_yml_patch::{apply_sources_yml_patches, resolve_provisioned_account_base_url, SourcesYmlPatch}, + model::{ConfigInput, ConfigProvider}, + repository::AliasExpDateSortOrder, +}; +use axum::http::StatusCode; +use shared::model::{ + ConfigInputAliasDto, ConfigInputDto, ConfigProviderDto, InputType, ProviderUrlSelectionPolicy, SourcesConfigDto, +}; +use std::{sync::Arc, time::Duration}; +use url::Url; + +fn source_alias(name: &str, exp_date: Option) -> ConfigInputAliasDto { + ConfigInputAliasDto { + id: 0, + name: Arc::from(name), + url: "provider://tivione".to_string(), + username: Some(format!("{name}-user")), + password: Some(format!("{name}-pass")), + priority: 0, + max_connections: 1, + exp_date, + enabled: true, + stalker: None, + } +} + +#[test] +fn batch_alias_storage_never_falls_back_to_source_yml() { + assert!(resolve_batch_alias_path(None).expect("non-batch input").is_none()); + assert!(resolve_batch_alias_path(Some("provider://not-a-csv")).is_err()); +} + +fn source_doc_with_aliases(aliases: Vec) -> SourcesConfigDto { + SourcesConfigDto { + inputs: vec![ConfigInputDto { + name: Arc::from("cdn-dev"), + input_type: InputType::Xtream, + url: "provider://tivione".to_string(), + username: Some("root-user".to_string()), + password: Some("root-pass".to_string()), + aliases: Some(aliases), + ..ConfigInputDto::default() + }], + ..SourcesConfigDto::default() + } +} + +#[test] +fn sources_yml_add_alias_appends_without_overwriting_existing_alias() { + let mut doc = source_doc_with_aliases(vec![source_alias("cdn-dev-old", Some(10))]); + doc.inputs[0].aliases.as_mut().expect("aliases")[0].id = 2; + + let changed = apply_sources_yml_patches( + &mut doc, + &[ + SourcesYmlPatch::AddAlias { + input_name: Arc::from("cdn-dev"), + alias_name: Arc::from("cdn-dev-new"), + base_url: "provider://tivione".to_string(), + username: "new-user".to_string(), + password: "new-pass".to_string(), + exp_date: Some(20), + }, + SourcesYmlPatch::SortAliases { + input_name: Arc::from("cdn-dev"), + order: AliasExpDateSortOrder::NewestFirst, + }, + ], + ) + .expect("patches apply"); + + assert!(changed); + let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); + assert_eq!(aliases.len(), 2); + assert_eq!(aliases[0].name.as_ref(), "cdn-dev-new"); + assert_eq!(aliases[0].id, 3); + assert_eq!(aliases[1].name.as_ref(), "cdn-dev-old"); + assert_eq!(aliases[1].id, 2); +} + +#[test] +fn sources_yml_sort_aliases_oldest_first_is_available_for_maintenance_paths() { + let mut doc = source_doc_with_aliases(vec![ + source_alias("cdn-dev-newest", Some(30)), + source_alias("cdn-dev-oldest", Some(10)), + source_alias("cdn-dev-missing-exp", None), + ]); + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::SortAliases { input_name: Arc::from("cdn-dev"), order: AliasExpDateSortOrder::OldestFirst }], + ) + .expect("patches apply"); + + assert!(changed); + let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); + assert_eq!(aliases[0].name.as_ref(), "cdn-dev-oldest"); + assert_eq!(aliases[1].name.as_ref(), "cdn-dev-newest"); + assert_eq!(aliases[2].name.as_ref(), "cdn-dev-missing-exp"); +} + +#[test] +fn sources_yml_update_exp_date_keeps_legacy_root_refresh_semantics() { + let mut doc = source_doc_with_aliases(Vec::new()); + doc.inputs[0].exp_date = Some(20); + doc.inputs[0].enabled = false; + doc.inputs[0].max_connections = 0; + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::UpdatePanelAccountExpiry { + input_name: Arc::from("cdn-dev"), + account_name: Arc::from("cdn-dev"), + exp_date: 20, + }], + ) + .expect("patches apply"); + + assert!(changed); + assert_eq!(doc.inputs[0].exp_date, Some(20)); + assert!(doc.inputs[0].enabled); + assert_eq!(doc.inputs[0].max_connections, 1); +} + +#[test] +fn sources_yml_update_exp_date_keeps_legacy_alias_refresh_semantics() { + let mut doc = source_doc_with_aliases(vec![source_alias("cdn-dev-old", Some(20))]); + doc.inputs[0].aliases.as_mut().expect("aliases")[0].max_connections = 0; + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::UpdatePanelAccountExpiry { + input_name: Arc::from("cdn-dev"), + account_name: Arc::from("cdn-dev-old"), + exp_date: 20, + }], + ) + .expect("patches apply"); + + assert!(changed); + let alias = &doc.inputs[0].aliases.as_ref().expect("aliases")[0]; + assert_eq!(alias.exp_date, Some(20)); + assert_eq!(alias.max_connections, 1); +} + +#[test] +fn sources_yml_update_root_credentials_updates_root_directly() { + let mut doc = source_doc_with_aliases(Vec::new()); + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::UpdateRootCredentials { + input_name: Arc::from("cdn-dev"), + username: "new-root".to_string(), + password: "new-pass".to_string(), + exp_date: Some(42), + }], + ) + .expect("patches apply"); + + assert!(changed); + assert_eq!(doc.inputs[0].username.as_deref(), Some("new-root")); + assert_eq!(doc.inputs[0].password.as_deref(), Some("new-pass")); + assert_eq!(doc.inputs[0].exp_date, Some(42)); + assert!(doc.inputs[0].enabled); + assert_eq!(doc.inputs[0].max_connections, 1); +} + +#[test] +fn sources_yml_persist_provisioned_account_adds_alias_when_current_root_is_valid() { + let mut doc = source_doc_with_aliases(Vec::new()); + doc.inputs[0].username = Some("current-root".to_string()); + doc.inputs[0].password = Some("current-pass".to_string()); + doc.inputs[0].exp_date = Some(i64::try_from(jsonwebtoken::get_current_timestamp()).expect("timestamp") + 3600); + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::PersistProvisionedAccount { + input_name: Arc::from("cdn-dev"), + username: "new-root".to_string(), + password: "new-pass".to_string(), + exp_date: Some(42), + }], + ) + .expect("patches apply"); + + assert!(changed); + assert_eq!(doc.inputs[0].username.as_deref(), Some("current-root")); + assert_eq!(doc.inputs[0].password.as_deref(), Some("current-pass")); + assert_ne!(doc.inputs[0].exp_date, Some(42)); + + let aliases = doc.inputs[0].aliases.as_ref().expect("aliases"); + assert_eq!(aliases.len(), 1); + assert_eq!(aliases[0].name.as_ref(), "cdn-dev-new-root"); + assert_eq!(aliases[0].username.as_deref(), Some("new-root")); + assert_eq!(aliases[0].password.as_deref(), Some("new-pass")); + assert_eq!(aliases[0].exp_date, Some(42)); +} + +#[test] +fn sources_yml_persist_provisioned_account_replaces_root_when_current_root_is_expired() { + let mut doc = source_doc_with_aliases(Vec::new()); + doc.inputs[0].username = Some("current-root".to_string()); + doc.inputs[0].password = Some("current-pass".to_string()); + doc.inputs[0].exp_date = Some(i64::try_from(jsonwebtoken::get_current_timestamp()).expect("timestamp") - 1); + + let changed = apply_sources_yml_patches( + &mut doc, + &[SourcesYmlPatch::PersistProvisionedAccount { + input_name: Arc::from("cdn-dev"), + username: "new-root".to_string(), + password: "new-pass".to_string(), + exp_date: Some(42), + }], + ) + .expect("patches apply"); + + assert!(changed); + assert_eq!(doc.inputs[0].username.as_deref(), Some("new-root")); + assert_eq!(doc.inputs[0].password.as_deref(), Some("new-pass")); + assert_eq!(doc.inputs[0].exp_date, Some(42)); + assert!(doc.inputs[0].aliases.as_ref().is_none_or(Vec::is_empty)); +} + +#[test] +fn panel_api_retryable_status_covers_rate_limit_and_temporary_failures() { + assert!(panel_api_retryable_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(panel_api_retryable_status(StatusCode::REQUEST_TIMEOUT)); + assert!(panel_api_retryable_status(StatusCode::TOO_EARLY)); + assert!(panel_api_retryable_status(StatusCode::BAD_GATEWAY)); + assert!(!panel_api_retryable_status(StatusCode::BAD_REQUEST)); + assert!(!panel_api_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(!panel_api_retryable_status(StatusCode::NOT_FOUND)); +} + +#[test] +fn panel_api_retry_after_header_is_short_and_bounded() { + assert_eq!(panel_api_retry_after_from_header_value("2"), Some(Duration::from_secs(2))); + assert_eq!( + panel_api_retry_after_from_header_value("0"), + Some(Duration::from_secs(PANEL_API_DEFAULT_RETRY_AFTER_SECS)) + ); + assert_eq!( + panel_api_retry_after_from_header_value("600"), + Some(Duration::from_secs(PANEL_API_MAX_RETRY_AFTER_SECS)) + ); + assert_eq!(panel_api_retry_after_from_header_value("not-a-delay"), None); +} + +#[test] +fn resolve_base_url_keeps_provider_scheme_for_provisioned_accounts() { + let result = resolve_provisioned_account_base_url( + "provider://demo-provider/live", + Some("http://panel.example.com:8080/get.php?username=new&password=new"), + "new", + "secret", + ); + + assert_eq!(result, "provider://demo-provider/live"); +} + +#[test] +fn resolve_base_url_updates_provider_query_credentials_when_present() { + let result = resolve_provisioned_account_base_url( + "provider://demo-provider/live?foo=bar&username=old&password=oldpw", + Some("http://panel.example.com:8080/get.php?username=new&password=new"), + "new-user", + "new-pass", + ); + + let parsed = Url::parse(result.as_str()).expect("expected valid provider url"); + let pairs: Vec<(String, String)> = parsed.query_pairs().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + assert!(pairs.contains(&("foo".to_string(), "bar".to_string()))); + assert!(pairs.contains(&("username".to_string(), "new-user".to_string()))); + assert!(pairs.contains(&("password".to_string(), "new-pass".to_string()))); +} + +#[test] +fn resolve_base_url_uses_panel_response_origin_for_http_inputs() { + let result = resolve_provisioned_account_base_url( + "http://input.example.org/path?x=1", + Some("http://panel.example.com:8080/get.php?username=new&password=new"), + "new", + "secret", + ); + + assert_eq!(result, "http://panel.example.com:8080"); +} + +#[test] +fn resolve_base_url_falls_back_when_panel_response_is_literal_null() { + let result = + resolve_provisioned_account_base_url("http://input.example.org/path?x=1", Some("null"), "new", "secret"); + + assert_eq!(result, "http://input.example.org/path?x=1"); +} + +#[test] +fn resolve_base_url_avoids_null_origin_for_non_special_schemes() { + let result = resolve_provisioned_account_base_url( + "http://input.example.org/path?x=1", + Some("custom-scheme://panel.example.com/path?username=new&password=new"), + "new", + "secret", + ); + + assert_eq!(result, "custom-scheme://panel.example.com/path?username=new&password=new"); +} + +#[test] +fn panel_api_probe_targets_preserve_provider_failover_context() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "tivione".into(), + urls: vec!["http://line-a.example.test".into(), "http://line-b.example.test".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input = ConfigInput { + name: Arc::from("cdn-dev"), + url: "provider://tivione".to_string(), + provider_configs: Some(vec![Arc::clone(&provider)]), + ..ConfigInput::default() + }; + + let targets = build_panel_api_probe_targets(&input, "probe-user", "probe-pass"); + + assert_eq!(targets.len(), 4); + let PanelApiProbeTarget::PlayerApi { action, input_source } = &targets[0]; + assert_eq!(*action, "client_info"); + assert_eq!(input_source.provider.as_ref().expect("provider context should be preserved").name.as_ref(), "tivione"); + assert!(input_source.url.starts_with("provider://tivione/player_api.php?")); + assert!(input_source.url.contains("username=probe-user")); + assert!(input_source.url.contains("password=probe-pass")); + assert!(input_source.url.contains("action=client_info")); +} + +#[test] +fn panel_api_probe_targets_keep_plain_http_without_provider_context() { + let input = ConfigInput { + name: Arc::from("plain"), + url: "http://origin.example.test/some/path?ignored=1".to_string(), + ..ConfigInput::default() + }; + + let targets = build_panel_api_probe_targets(&input, "probe-user", "probe-pass"); + + assert_eq!(targets.len(), 4); + let PanelApiProbeTarget::PlayerApi { action, input_source } = &targets[0]; + assert_eq!(*action, "client_info"); + assert!(input_source.provider.is_none()); + assert!(input_source.url.starts_with("http://origin.example.test/player_api.php?")); + assert!(input_source.url.contains("username=probe-user")); + assert!(input_source.url.contains("password=probe-pass")); + assert!(input_source.url.contains("action=client_info")); +} + +#[test] +fn user_api_account_info_preserves_provider_failover_context() { + let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { + name: "tivione".into(), + urls: vec!["http://line-a.example.test".into(), "http://line-b.example.test".into()], + provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking, + dns: None, + })); + let input = ConfigInput { + name: Arc::from("cdn-dev"), + url: "provider://tivione".to_string(), + provider_configs: Some(vec![Arc::clone(&provider)]), + ..ConfigInput::default() + }; + + let input_source = build_user_api_account_info_input_source(&input, "root-user", "root-pass") + .expect("expected provider account_info input source"); + + assert_eq!(input_source.provider.as_ref().expect("provider context should be preserved").name.as_ref(), "tivione"); + assert!(input_source.url.starts_with("provider://tivione/player_api.php?")); + assert!(input_source.url.contains("username=root-user")); + assert!(input_source.url.contains("password=root-pass")); + assert!(input_source.url.contains("action=account_info")); +} + +#[test] +fn user_api_account_info_keeps_plain_http_without_provider_context() { + let input = ConfigInput { + name: Arc::from("plain"), + url: "http://origin.example.test/some/path?ignored=1".to_string(), + ..ConfigInput::default() + }; + + let input_source = build_user_api_account_info_input_source(&input, "root-user", "root-pass") + .expect("expected plain account_info input source"); + + assert!(input_source.provider.is_none()); + assert!(input_source.url.starts_with("http://origin.example.test/player_api.php?")); + assert!(input_source.url.contains("username=root-user")); + assert!(input_source.url.contains("password=root-pass")); + assert!(input_source.url.contains("action=account_info")); +} diff --git a/backend/app/src/api/source_yml_patch.rs b/backend/app/src/api/source_yml_patch.rs index 0cf7f55f8..56744d82d 100644 --- a/backend/app/src/api/source_yml_patch.rs +++ b/backend/app/src/api/source_yml_patch.rs @@ -863,20 +863,19 @@ pub(crate) async fn execute_source_yml_patches( let _lock = app_config.file_locks.write_lock(sources_path).await; - // Step 2: Read original bytes + // Read and fingerprint the exact bytes that the transaction is based on. let original_bytes = tokio::fs::read(sources_path) .await .map_err(|err| TuliproxError::ConfigPanelApi(format!("source.yml patch: failed to read file: {err}")))?; - // Step 3: Compute revision let original_revision = blake3::hash(&original_bytes); let original_text = String::from_utf8(original_bytes) .map_err(|_| TuliproxError::ConfigPanelApi("source.yml patch: file is not valid UTF-8".to_string()))?; - // Step 4: Parse as SourcesConfigDto + // Validate the current document before applying any edits. let before: SourcesConfigDto = serde_saphyr::from_str(&original_text) .map_err(|err| TuliproxError::ConfigPanelApi(format!("source.yml patch: failed to parse source.yml: {err}")))?; - // Steps 5-9: Batch consecutive scalar commands into one clone/parse/validation pass. + // Batch consecutive scalar commands into one clone, parse, and validation pass. // Structural alias commands remain sequential because they change the byte spans used by // subsequent edits. The complete command list still produces one final atomic disk write. let mut expected = before; @@ -896,18 +895,13 @@ pub(crate) async fn execute_source_yml_patches( patch_index = step_end; } - // Step 6: No-op check if !changed { return Ok(false); } - // Step 10: Verify bytes outside edit ranges came from original - // (implicitly guaranteed by the edit model — apply_scalar_edits only modifies declared ranges) - - // Step 11: Re-read and check revision let backup_dir = app_config.config.load().get_backup_dir().to_string(); - // Step 12-13: Write with backup and atomic replace (includes revision check) + // Back up and atomically replace the file if its revision is still current. let written = write_config_text_file( sources_path.to_string_lossy().as_ref(), &backup_dir, @@ -918,7 +912,7 @@ pub(crate) async fn execute_source_yml_patches( .await?; if written { - // Step 14: Mark internal write revision + // Let the file watcher distinguish this write from an external edit. app_config .file_locks .mark_internal_write_revision(sources_path) diff --git a/backend/app/src/api/tasks/scheduler.rs b/backend/app/src/api/tasks/scheduler.rs index 675c07d9d..51cf9cd82 100644 --- a/backend/app/src/api/tasks/scheduler.rs +++ b/backend/app/src/api/tasks/scheduler.rs @@ -419,7 +419,6 @@ mod tests { // NOT: ProcessTargets { enabled: true, inputs: [], targets: [100] } // (which would have been produced by the old code filtering inputs against forced_targets.inputs) // - // Full integration test requires mock AppConfig with populated sources/targets. - // See: docs/superpowers/plans/2026-03-24-playlist-update-silent-failures.md Task 6 + // A full integration test requires an AppConfig populated with sources and targets. } } diff --git a/backend/auth/Cargo.toml b/backend/auth/Cargo.toml index c087ecb24..64ebabea5 100644 --- a/backend/auth/Cargo.toml +++ b/backend/auth/Cargo.toml @@ -23,5 +23,6 @@ log.workspace = true rand = "0.9.5" rust-argon2 = "3.0.0" serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } +thiserror.workspace = true rpassword = "7.5.4" zeroize.workspace = true diff --git a/backend/auth/src/authenticator.rs b/backend/auth/src/authenticator.rs index 4dea3ad49..003d16796 100644 --- a/backend/auth/src/authenticator.rs +++ b/backend/auth/src/authenticator.rs @@ -146,28 +146,34 @@ pub fn is_api_user(token_data: Option>) -> bool { has_role(tok /// "token-refresh-required" response lets the frontend sign out /// without guessing. The HTTP layer returns 401 with an /// `X-Token-Refresh: required` header for refresh-required cases. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum AuthError { /// Token signature/issuer/exp invalid, malformed, or otherwise /// unverifiable. The frontend should sign the user out. + #[error("token is invalid or expired")] InvalidToken, /// Token signature is valid but it carries an old or absent /// `permission_schema_version`. The frontend must refresh /// credentials to receive a token at the current schema. + #[error("token was issued for an older permission schema; refresh required")] StaleSchema, /// Token signature is valid but it lacks a `subject_id`. The /// identity-registry-bound principal cannot be resolved. + #[error("token is missing a subject_id; refresh required")] MissingSubject, /// Token signature is valid but it was minted against a password that has /// since changed - or it carries no password version at all. Either way /// the principal must authenticate again; a refresh cannot help, because /// the refresh endpoint applies the same check. + #[error("token was issued for a different password; sign in again")] PasswordChanged, /// Token signature is valid but it was issued before a revocation that /// covers it - the principal was signed out, or every session was. + #[error("token has been revoked; sign in again")] Revoked, /// Token signature is valid but the principal has the wrong /// role/permission for the requested endpoint. + #[error("principal does not have the required role")] Forbidden, } @@ -178,19 +184,6 @@ impl AuthError { pub fn is_token_refresh_required(self) -> bool { matches!(self, Self::StaleSchema | Self::MissingSubject) } } -impl std::fmt::Display for AuthError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidToken => f.write_str("token is invalid or expired"), - Self::StaleSchema => f.write_str("token was issued for an older permission schema; refresh required"), - Self::MissingSubject => f.write_str("token is missing a subject_id; refresh required"), - Self::PasswordChanged => f.write_str("token was issued for a different password; sign in again"), - Self::Revoked => f.write_str("token has been revoked; sign in again"), - Self::Forbidden => f.write_str("principal does not have the required role"), - } - } -} - /// Validate a verified token's `permission_schema_version` and /// `subject_id`. Both must be present and current. Returns /// [`AuthError::StaleSchema`] when the schema is below the current diff --git a/backend/btree/Cargo.toml b/backend/btree/Cargo.toml index 62f1c38c4..a53e90ff0 100644 --- a/backend/btree/Cargo.toml +++ b/backend/btree/Cargo.toml @@ -28,6 +28,7 @@ smallvec = "1.15.2" # `tempfile` and `uuid` are production dependencies here, not test-only: the v3 # migration path stages its output through a uniquely named temporary file. tempfile = "3.27.0" +thiserror.workspace = true uuid = { version = "1.26.0", features = ["v4"] } [dev-dependencies] diff --git a/backend/btree/src/common.rs b/backend/btree/src/common.rs index b331db6da..24059d38b 100644 --- a/backend/btree/src/common.rs +++ b/backend/btree/src/common.rs @@ -225,38 +225,18 @@ pub(crate) fn require_same_parent_directory(staging: &Path, published: &Path) -> } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum BPlusTreeError { - Io(io::Error), + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("Data corrupted: {0}")] Corrupted(String), + #[error("Invalid structure: {0}")] InvalidStructure(String), + #[error("Key not found")] KeyNotFound, } -impl std::fmt::Display for BPlusTreeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(err) => write!(f, "I/O error: {err}"), - Self::Corrupted(msg) => write!(f, "Data corrupted: {msg}"), - Self::InvalidStructure(msg) => write!(f, "Invalid structure: {msg}"), - Self::KeyNotFound => write!(f, "Key not found"), - } - } -} - -impl std::error::Error for BPlusTreeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - Self::Corrupted(_) | Self::InvalidStructure(_) | Self::KeyNotFound => None, - } - } -} - -impl From for BPlusTreeError { - fn from(err: io::Error) -> Self { Self::Io(err) } -} - impl BPlusTreeError { pub fn to_io(self) -> io::Error { match self { diff --git a/backend/btree/src/stress.rs b/backend/btree/src/stress.rs index 4493c6207..ed5f6e175 100644 --- a/backend/btree/src/stress.rs +++ b/backend/btree/src/stress.rs @@ -57,9 +57,9 @@ fn stress_test_bplustree() { writeln!(log_file, "Insert benchmark runs: {insert_runs}").unwrap(); // ---------------------------------------------------------------- - // Phase 1: Batch Insert (Sequential Keys) + // Step 1: Batch Insert (Sequential Keys) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 1] Batch Insert (Sequential, Multi-Run)...").unwrap(); + writeln!(log_file, "\n[Step 1] Batch Insert (Sequential, Multi-Run)...").unwrap(); let start_gen = Instant::now(); let mut value_pool = Vec::with_capacity(value_pool_size); @@ -104,9 +104,9 @@ fn stress_test_bplustree() { let query_subset_mem = &query_keys[0..query_count]; // ---------------------------------------------------------------- - // Phase 1b: Memory-Only Random Query (Before storing) + // Step 1b: Memory-Only Random Query (Before storing) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 1b] Memory-Only Random Query ({query_count} items)...").unwrap(); + writeln!(log_file, "\n[Step 1b] Memory-Only Random Query ({query_count} items)...").unwrap(); let start = Instant::now(); for k in query_subset_mem { let _ = tree.query(k); @@ -116,9 +116,9 @@ fn stress_test_bplustree() { writeln!(log_file, "Throughput: {:.0} ops/sec", query_count as f64 / duration.as_secs_f64()).unwrap(); // ---------------------------------------------------------------- - // Phase 1c: Store to disk + // Step 1c: Store to disk // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 1c] Store to disk...").unwrap(); + writeln!(log_file, "\n[Step 1c] Store to disk...").unwrap(); let start = Instant::now(); tree.store(&filepath).unwrap(); drop(tree); @@ -128,9 +128,9 @@ fn stress_test_bplustree() { writeln!(log_file, "File Size: {:.2} MB", size_phase1 as f64 / 1024.0 / 1024.0).unwrap(); // ---------------------------------------------------------------- - // Phase 2: Random Query (Disk-based) + // Step 2: Random Query (Disk-based) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 2] Random Query Disk-based ({query_count} items)...").unwrap(); + writeln!(log_file, "\n[Step 2] Random Query Disk-based ({query_count} items)...").unwrap(); let mut query = BPlusTreeQuery::::try_new(&filepath).unwrap(); let start = Instant::now(); for k in query_subset_mem { @@ -142,9 +142,9 @@ fn stress_test_bplustree() { drop(query); // ---------------------------------------------------------------- - // Phase 3: Batch Update (In-Place Packed) + // Step 3: Batch Update (In-Place Packed) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 3] Batch Update (In-Place Packed)...").unwrap(); + writeln!(log_file, "\n[Step 3] Batch Update (In-Place Packed)...").unwrap(); let update_count = 5000; let update_subset = &query_keys[0..update_count]; let updates: Vec<(u32, String)> = update_subset.iter().map(|&k| (k, random_string(small_val_len))).collect(); @@ -159,9 +159,9 @@ fn stress_test_bplustree() { writeln!(log_file, "File Size: {:.2} MB", size_phase3 as f64 / 1024.0 / 1024.0).unwrap(); // ---------------------------------------------------------------- - // Phase 4: Batch Update (Promoting Packed -> Single) + // Step 4: Batch Update (Promoting Packed -> Single) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 4] Batch Update (Promotion to Single)...").unwrap(); + writeln!(log_file, "\n[Step 4] Batch Update (Promotion to Single)...").unwrap(); let updates_prom: Vec<(u32, String)> = update_subset.iter().map(|&k| (k, random_string(large_val_len))).collect(); let update_refs_prom: Vec<(&u32, &String)> = updates_prom.iter().map(|(k, v)| (k, v)).collect(); let start = Instant::now(); @@ -173,9 +173,9 @@ fn stress_test_bplustree() { writeln!(log_file, "File Size: {:.2} MB", size_phase4 as f64 / 1024.0 / 1024.0).unwrap(); // ---------------------------------------------------------------- - // Phase 5: Batch Delete + // Step 5: Batch Delete // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 5] Batch Delete...").unwrap(); + writeln!(log_file, "\n[Step 5] Batch Delete...").unwrap(); let delete_count = 100_000usize; let delete_start_idx = update_count; let delete_end_idx = delete_start_idx + delete_count; @@ -200,9 +200,9 @@ fn stress_test_bplustree() { writeln!(log_file, "File Size: {:.2} MB", size_phase5 as f64 / 1024.0 / 1024.0).unwrap(); // ---------------------------------------------------------------- - // Phase 5b: Reinsert Deleted Keys + // Step 5b: Reinsert Deleted Keys // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 5b] Reinsert Deleted Keys...").unwrap(); + writeln!(log_file, "\n[Step 5b] Reinsert Deleted Keys...").unwrap(); let reinserts: Vec<(u32, String)> = delete_subset.iter().map(|&k| (k, random_string(small_val_len))).collect(); let reinsert_refs: Vec<(&u32, &String)> = reinserts.iter().map(|(k, v)| (k, v)).collect(); let start = Instant::now(); @@ -215,9 +215,9 @@ fn stress_test_bplustree() { drop(tree_updater); // ---------------------------------------------------------------- - // Phase 6: Compaction + // Step 6: Compaction // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 6] Compaction...").unwrap(); + writeln!(log_file, "\n[Step 6] Compaction...").unwrap(); let mut tree_updater = BPlusTreeUpdate::::try_new(&filepath).unwrap(); let start = Instant::now(); tree_updater.compact().unwrap(); @@ -234,9 +234,9 @@ fn stress_test_bplustree() { drop(tree_updater); // ---------------------------------------------------------------- - // Phase 7: Full Tree Load and In-Memory Query + // Step 7: Full Tree Load and In-Memory Query // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 7] Full Tree Load (Memory-Only Read)...").unwrap(); + writeln!(log_file, "\n[Step 7] Full Tree Load (Memory-Only Read)...").unwrap(); let start = Instant::now(); let tree_mem = BPlusTree::::load(&filepath).unwrap(); let load_duration = start.elapsed(); @@ -252,9 +252,9 @@ fn stress_test_bplustree() { drop(tree_mem); // ---------------------------------------------------------------- - // Phase 8: Concurrent Readers + Writers (Disk-based) + // Step 8: Concurrent Readers + Writers (Disk-based) // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 8] Concurrent Readers + Writers (Disk-based)...").unwrap(); + writeln!(log_file, "\n[Step 8] Concurrent Readers + Writers (Disk-based)...").unwrap(); let reader_threads = 8usize; let writer_threads = 4usize; let reader_ops_per_thread = 40_000u64; @@ -397,9 +397,9 @@ fn stress_test_bplustree() { assert_eq!(total_reader_misses, 0, "all reader queries should resolve during concurrent load"); // ---------------------------------------------------------------- - // Phase 8b: Post-Concurrency Verification + // Step 8b: Post-Concurrency Verification // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 8b] Post-Concurrency Verification...").unwrap(); + writeln!(log_file, "\n[Step 8b] Post-Concurrency Verification...").unwrap(); let mut verify_query = BPlusTreeQuery::::try_new(&filepath).unwrap(); let verification_samples = 20_000u64; let mut verification_misses = 0u64; @@ -432,9 +432,9 @@ fn stress_test_bplustree() { drop(verify_query); // ---------------------------------------------------------------- - // Phase 9: Iterator + query_le Traversal + // Step 9: Iterator + query_le Traversal // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 9] Iterator + query_le Traversal...").unwrap(); + writeln!(log_file, "\n[Step 9] Iterator + query_le Traversal...").unwrap(); let mut iter_query = BPlusTreeQuery::::try_new(&filepath).unwrap(); let iterator_target = 100_000usize; let iter_start = Instant::now(); @@ -478,9 +478,9 @@ fn stress_test_bplustree() { drop(iter_query); // ---------------------------------------------------------------- - // Phase 10: Final Delete + Compact Verification + // Step 10: Final Delete + Compact Verification // ---------------------------------------------------------------- - writeln!(log_file, "\n[Phase 10] Final Delete + Compact Verification...").unwrap(); + writeln!(log_file, "\n[Step 10] Final Delete + Compact Verification...").unwrap(); let final_delete_count = 100_000usize; let final_delete_start = delete_end_idx; let final_delete_end = final_delete_start + final_delete_count; diff --git a/backend/core/src/model/stream_error.rs b/backend/core/src/model/stream_error.rs index 8f5b25599..6f0d08bb1 100644 --- a/backend/core/src/model/stream_error.rs +++ b/backend/core/src/model/stream_error.rs @@ -1,17 +1,26 @@ use std::error::Error; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, thiserror::Error)] pub enum StreamError { + #[error("Reqwest error: {message}")] Reqwest { message: String, class: &'static str, status: Option }, + #[error("IO error: {0}")] StdIo(String), + #[error("Content decoding error: {0}")] ContentDecoding(String), // ReceiverClosed, - ReceiverError(BroadcastStreamRecvError), + #[error("Receiver error {0}")] + ReceiverError(#[from] BroadcastStreamRecvError), + #[error("LockError: {0}")] LockError(String), + #[error("Stream: {0}")] Stream(String), + #[error("MalformedPacket: {0}")] MalformedPacket(String), + #[error("InvalidTimestamp: {0}")] InvalidTimestamp(String), + #[error("SyncLoss: {0}")] SyncLoss(String), } @@ -100,25 +109,6 @@ impl StreamError { } } -impl std::error::Error for StreamError {} - -impl std::fmt::Display for StreamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StreamError::Reqwest { message, .. } => write!(f, "Reqwest error: {message}"), - StreamError::StdIo(e) => write!(f, "IO error: {e}"), - StreamError::ContentDecoding(e) => write!(f, "Content decoding error: {e}"), - // StreamError::ReceiverClosed => write!(f, "Receiver closed"), - StreamError::ReceiverError(e) => write!(f, "Receiver error {e}"), - StreamError::Stream(e) => write!(f, "Stream: {e}"), - StreamError::LockError(e) => write!(f, "LockError: {e}"), - StreamError::MalformedPacket(e) => write!(f, "MalformedPacket: {e}"), - StreamError::InvalidTimestamp(e) => write!(f, "InvalidTimestamp: {e}"), - StreamError::SyncLoss(e) => write!(f, "SyncLoss: {e}"), - } - } -} - #[cfg(test)] mod tests { use super::StreamError; diff --git a/backend/core/src/utils/atomic_json_store.rs b/backend/core/src/utils/atomic_json_store.rs index 1654404ab..d407eaf91 100644 --- a/backend/core/src/utils/atomic_json_store.rs +++ b/backend/core/src/utils/atomic_json_store.rs @@ -23,9 +23,11 @@ pub enum AtomicWriteStage { } /// Errors that wrap a caller-supplied [`std::io::Error`] with stage context. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] +#[error("atomic write failed at {stage:?}: {source}")] pub struct AtomicWriteError { pub stage: AtomicWriteStage, + #[source] pub source: std::io::Error, } @@ -33,16 +35,6 @@ impl AtomicWriteError { pub fn new(stage: AtomicWriteStage, source: std::io::Error) -> Self { Self { stage, source } } } -impl std::fmt::Display for AtomicWriteError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "atomic write failed at {:?}: {}", self.stage, self.source) - } -} - -impl std::error::Error for AtomicWriteError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.source) } -} - impl From for std::io::Error { fn from(err: AtomicWriteError) -> Self { std::io::Error::new(err.source.kind(), err) } } diff --git a/backend/core/src/utils/recording_paths.rs b/backend/core/src/utils/recording_paths.rs index 7f9974333..29a48bc3c 100644 --- a/backend/core/src/utils/recording_paths.rs +++ b/backend/core/src/utils/recording_paths.rs @@ -31,37 +31,24 @@ pub enum RecordingVisibility { } /// Errors that can occur when handling a recording path. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum RecordingPathError { + #[error("path is empty")] Empty, + #[error("path is absolute")] Absolute, + #[error("path contains '.' or '..' or other invalid component")] InvalidComponent, + #[error("path contains a NUL byte")] NulByte, + #[error("path is not a regular file")] NotARegularFile, + #[error("path is not within the recording root")] NotWithinRoot, + #[error("path already exists")] AlreadyExists, - Io(io::Error), -} - -impl std::fmt::Display for RecordingPathError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Empty => f.write_str("path is empty"), - Self::Absolute => f.write_str("path is absolute"), - Self::InvalidComponent => f.write_str("path contains '.' or '..' or other invalid component"), - Self::NulByte => f.write_str("path contains a NUL byte"), - Self::NotARegularFile => f.write_str("path is not a regular file"), - Self::NotWithinRoot => f.write_str("path is not within the recording root"), - Self::AlreadyExists => f.write_str("path already exists"), - Self::Io(err) => write!(f, "io error: {err}"), - } - } -} - -impl std::error::Error for RecordingPathError {} - -impl From for RecordingPathError { - fn from(err: io::Error) -> Self { Self::Io(err) } + #[error("io error: {0}")] + Io(#[from] io::Error), } impl From for io::Error { diff --git a/backend/dvr/Cargo.toml b/backend/dvr/Cargo.toml index 9807cda12..6faa6eec2 100644 --- a/backend/dvr/Cargo.toml +++ b/backend/dvr/Cargo.toml @@ -24,6 +24,7 @@ log.workspace = true reqwest = { version = "0.13.4", features = ["json", "stream", "rustls", "socks", "form", "query"] } serde.workspace = true serde_json.workspace = true +thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "fs", "time", "macros", "sync", "process"] } tokio-util = { version = "0.7.19", features = ["io", "io-util"] } url.workspace = true diff --git a/backend/dvr/src/download.rs b/backend/dvr/src/download.rs index 095ead0b0..c741e5d5d 100644 --- a/backend/dvr/src/download.rs +++ b/backend/dvr/src/download.rs @@ -35,51 +35,54 @@ static DOWNLOAD_TASK_ID_COUNTER: AtomicU64 = AtomicU64::new(1); /// Reason a persisted entry cannot be converted back to its in-memory /// form during the commit step. Surfaced to the caller so a corrupt /// persisted file fails closed instead of silently dropping entries. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum PersistedError { /// The persisted URL could not be parsed. + #[error("persisted url is invalid: {0}")] InvalidUrl(String), /// A plain download claimed a recording metadata block, or a /// recording was missing its metadata in a way that the legacy /// normalizer cannot repair. + #[error("persisted task {uuid} violates the kind/metadata invariant")] KindMetadataInvariant { uuid: String }, } -impl std::fmt::Display for PersistedError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidUrl(s) => write!(f, "persisted url is invalid: {s}"), - Self::KindMetadataInvariant { uuid } => { - write!(f, "persisted task {uuid} violates the kind/metadata invariant") - } - } - } -} - -impl std::error::Error for PersistedError {} - /// Typed error returned from the queue mutation boundary. Every /// `mutate` closure that fails must return a known variant; the /// `Other` variant is an escape hatch for dynamically-formatted /// messages that have no stable wire code. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum QueueMutationError { + #[error("recording unknown")] UnknownRecording, + #[error("recording state not editable")] StateNotEditable, + #[error("recording forbidden")] Forbidden, + #[error("recording invalid interval")] InvalidInterval, + #[error("recording invalid quota pool")] InvalidQuotaPool, + #[error("recording invalid path")] InvalidPath, + #[error("recording_padding_limit_exceeded")] PaddingLimitExceeded, + #[error("recording quota exceeded")] QuotaExceeded, + #[error("recording duplicate")] Duplicate, + #[error("recording not in terminal state")] NotInTerminalState, + #[error("disk full")] DiskFull, + #[error("mutation unexpectedly skipped")] MutationSkipped, /// Escape hatch for dynamically-formatted validation messages /// that have no stable wire code. Prefer the typed variants. + #[error("{0}")] Other(String), - Io(std::io::Error), + #[error("queue mutation persistence failed")] + Io(#[from] std::io::Error), } impl QueueMutationError { @@ -120,25 +123,6 @@ impl QueueMutationError { } } -impl std::fmt::Display for QueueMutationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Other(s) => f.write_str(s), - Self::Io(e) => std::fmt::Display::fmt(e, f), - other => f.write_str(other.message()), - } - } -} - -impl std::error::Error for QueueMutationError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(err) => Some(err as &(dyn std::error::Error + 'static)), - _ => None, - } - } -} - /// Lock ordering for the queue mutation boundary: /// /// 1. `mutation_guard` (`Mutex`) — outermost for persisted mutations and ordered control publication. @@ -2680,7 +2664,9 @@ mod tests { let result: Result<(), QueueMutationError> = mutate(&queue, |_candidate| Ok(())).await; assert!(result.is_err(), "persist failure should propagate"); - assert!(result.unwrap_err().source_io().is_some(), "should carry io::Error"); + let err = result.unwrap_err(); + assert_eq!(err.to_string(), "queue mutation persistence failed"); + assert!(std::error::Error::source(&err).is_some(), "should carry io::Error as its source"); // State stays unchanged: the in-memory queue is intact. assert_eq!(queue.queue.lock().await.len(), original_len, "in-memory state must be unchanged"); assert_eq!(queue.revision.load(Ordering::SeqCst), original_revision); diff --git a/backend/dvr/src/recording/recording_deletion.rs b/backend/dvr/src/recording/recording_deletion.rs index d3916bfc2..c9381f6a6 100644 --- a/backend/dvr/src/recording/recording_deletion.rs +++ b/backend/dvr/src/recording/recording_deletion.rs @@ -26,44 +26,35 @@ use std::path::{Path, PathBuf}; use tuliprox_core::utils::{no_follow_existing, safe_unlink}; /// Errors that can occur during the three phases. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum DeletionError { /// The UUID did not match any task in the queue. + #[error("recording not found")] UnknownTask, /// The matched task is not a recording. + #[error("task is not a recording")] NotARecording, /// The matched task is not in a terminal state, so deletion cannot /// begin. + #[error("recording is not in a terminal state")] NotTerminal, /// The caller is not permitted to delete this recording. Reported /// from inside the mutation boundary so authorization and the state /// transition observe the same task. + #[error("recording deletion forbidden")] Forbidden, /// Marking the recording as `Deleting` failed. - BeginFailed(QueueMutationError), + #[error("begin deletion failed: {0}")] + BeginFailed(#[source] QueueMutationError), /// File deletion failed in a way that is not safe to /// ignore. - DeleteFailed(std::io::Error), + #[error("physical delete failed: {0}")] + DeleteFailed(#[source] std::io::Error), /// Removing the task from the queue failed. - FinalizeFailed(QueueMutationError), + #[error("finalize deletion failed: {0}")] + FinalizeFailed(#[source] QueueMutationError), } -impl std::fmt::Display for DeletionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::UnknownTask => f.write_str("recording not found"), - Self::NotARecording => f.write_str("task is not a recording"), - Self::NotTerminal => f.write_str("recording is not in a terminal state"), - Self::Forbidden => f.write_str("recording deletion forbidden"), - Self::BeginFailed(err) => write!(f, "begin deletion failed: {err}"), - Self::DeleteFailed(err) => write!(f, "physical delete failed: {err}"), - Self::FinalizeFailed(err) => write!(f, "finalize deletion failed: {err}"), - } - } -} - -impl std::error::Error for DeletionError {} - /// Locate a recording task in the candidate by uuid. Returns /// `(bucket, index)` where `bucket` is one of `"queue"`, `"scheduled"`, /// `"active"`, `"finished"`. Returns `None` if the uuid is not in the diff --git a/backend/hls/src/gc.rs b/backend/hls/src/gc.rs index b1b87a105..b2116c27d 100644 --- a/backend/hls/src/gc.rs +++ b/backend/hls/src/gc.rs @@ -1596,7 +1596,7 @@ mod tests { TransientResourceRef, }; use std::{collections::HashSet, fmt::Write as _, sync::Arc, task::Poll, time::Duration}; - use tokio::sync::RwLock; + use tokio::sync::{Barrier, RwLock}; use tuliprox_parser::hls::origin_manifest::{ parse_manifest_semantics, parse_origin_media_manifest, OriginManifestParseOutcome, }; @@ -2438,10 +2438,28 @@ mod tests { session.proxy_session_id.clone(), ) }; + let first_staged = gc + .cache + .stage_temp_with_deadline(&third, &b"abcdefghij"[..], tokio::time::Instant::now() + Duration::from_mins(1)) + .await + .expect("first object stages"); + let second_staged = gc + .cache + .stage_temp_with_deadline(&fourth, &b"klmnopqrst"[..], tokio::time::Instant::now() + Duration::from_mins(1)) + .await + .expect("second object stages"); + let commit_barrier = Arc::new(Barrier::new(2)); let first_cache = Arc::clone(&gc.cache); - let first = tokio::spawn(async move { first_cache.write_bytes_and_commit(&third, b"abcdefghij").await }); + let first_barrier = Arc::clone(&commit_barrier); + let first = tokio::spawn(async move { + first_barrier.wait().await; + first_cache.commit_staged(&third, first_staged).await + }); let second_cache = Arc::clone(&gc.cache); - let second = tokio::spawn(async move { second_cache.write_bytes_and_commit(&fourth, b"klmnopqrst").await }); + let second = tokio::spawn(async move { + commit_barrier.wait().await; + second_cache.commit_staged(&fourth, second_staged).await + }); let (first, second) = tokio::join!(first, second); diff --git a/backend/hls/src/lib.rs b/backend/hls/src/lib.rs index ec6400c95..dbd4b1f80 100644 --- a/backend/hls/src/lib.rs +++ b/backend/hls/src/lib.rs @@ -12,39 +12,9 @@ //! HLS shared-session cache: origin fetcher, segment/map/transient/manifest stores, //! access-lease protocol, GC, observability, and origin-request header policy. //! -//! # Subsystem map (29 flat files → 7 logical groups) -//! -//! The flat layout below predates the cache state machine's growth; many files -//! span concerns (e.g. `paths.rs`/`deadline.rs`/`ids.rs` are pure infra). -//! The natural cohesion boundaries are: -//! -//! | Group | Files (current name → natural home) | -//! |--------------|------------------------------------------------------------------------------------| -//! | `session` | `session`, `session_store`, `lifecycle`, `ids` (session-token helpers) | -//! | `segment` | `segment_fetcher`, `segment_repair`, `segment_watchdog` | -//! | `map` | `map`, `map_fetcher` | -//! | `manifest` | `manifest_commit`, `manifest_fetch`, `transient`, `transient_fetcher` | -//! | `lease` | `lease` | -//! | `gc` | `gc` | -//! | `infra` | `ids` (token types), `deadline`, `paths`, `headers` (now via `proxy::header_policy`), `backpressure`, `observability`, `timeline`, `qos`, `cache`, `manager`, `refresh`, `renderer`, `response`, `prefetch`, `origin`, `playback`, `resource_fetch` | -//! -//! `proxy/header_policy` already exists as a cross-proxy module (see -//! `api::model::proxy::header_policy::HopByHopHeader`); `headers.rs` is now a -//! thin delegator. The remaining 28 files are intentionally left in place -//! because the cost of moving them (imports, mod.rs churn, public-API re-exports) -//! outweighs the discoverability gain at this commit. Each subsystem -//! migration is a self-contained follow-up PR. -//! -//! Migration order (lowest risk first): -//! 1. `infra` (only `proxy::header_policy` already done; rest stay flat) -//! 2. `lease`, `gc` (each one self-contained today) -//! 3. `map` (one fetcher, one store) -//! 4. `manifest` + `segment` (share transient types; do together) -//! 5. `session` last (largest blast radius; touches lifecycle, store, ids) -//! -//! Until the move lands, treat the table above as the canonical "where do I -//! put this?" map. New files should land in the natural group, not the flat -//! layout. +//! The implementation is split by runtime responsibility: session lifecycle, +//! segment and map fetching, manifest refresh and acceptance, leases, cache +//! collection, playback responses, and observability. // The `test-support` surface is compiled for *other* crates' tests. From inside // this crate nothing calls it, so `dead_code` fires on every helper; the lint is diff --git a/backend/hls/src/manifest_fetch/model.rs b/backend/hls/src/manifest_fetch/model.rs index 22db8ca4d..bd22705c0 100644 --- a/backend/hls/src/manifest_fetch/model.rs +++ b/backend/hls/src/manifest_fetch/model.rs @@ -8,7 +8,7 @@ use axum::http::{HeaderMap, StatusCode}; use reqwest::Client; use shared::model::InputFetchMethod; use std::{collections::HashMap, fmt, sync::Arc}; -use tuliprox_core::model::{AppConfig, ConfigProvider, HlsManifestRecoveryBurstConfig, InputSource}; +use tuliprox_core::model::{AppConfig, ConfigProvider, HlsManifestRecoveryBurstConfig, InputSource, ProviderConfig}; use url::Url; pub(super) const DEFAULT_HLS_TARGET_DURATION_SECS: u32 = 15; @@ -22,6 +22,7 @@ pub use crate::manifest_limits::MAX_HLS_MANIFEST_BYTES; pub struct LiveHlsOriginEntry { url: Url, url_failover_provider: Option>, + runtime_provider_config: Option>, } impl LiveHlsOriginEntry { @@ -31,7 +32,15 @@ impl LiveHlsOriginEntry { url: &str, url_failover_provider: Option>, ) -> Option { - Url::parse(url).ok().map(|url| Self { url, url_failover_provider }) + Self::parse_with_provider_configs(url, url_failover_provider, None) + } + + pub fn parse_with_provider_configs( + url: &str, + url_failover_provider: Option>, + runtime_provider_config: Option>, + ) -> Option { + Url::parse(url).ok().map(|url| Self { url, url_failover_provider, runtime_provider_config }) } pub fn url(&self) -> &Url { &self.url } @@ -39,14 +48,19 @@ impl LiveHlsOriginEntry { pub fn url_failover_provider(&self) -> Option<&Arc> { self.url_failover_provider.as_ref() } pub fn to_input_source(&self) -> InputSource { + let user_info = if self.url.scheme() == "provider" { + self.runtime_provider_config.as_ref().and_then(|provider| provider.get_user_info()) + } else { + None + }; InputSource { name: Arc::::from("hls-origin"), url: self.url.to_string(), // In this HLS context, InputSource.provider is the URL-failover provider from source.yml, // not a runtime origin-account provider. provider: self.url_failover_provider.clone(), - username: None, - password: None, + username: user_info.as_ref().map(|info| info.username.clone()), + password: user_info.map(|info| info.password), method: InputFetchMethod::GET, headers: HashMap::new(), } @@ -60,6 +74,10 @@ impl fmt::Debug for LiveHlsOriginEntry { .field("host", &self.url.host_str().unwrap_or("")) .field("path", &"") .field("url_failover_provider", &self.url_failover_provider.as_ref().map(|provider| provider.name.as_ref())) + .field( + "runtime_provider_config", + &self.runtime_provider_config.as_ref().map(|provider| provider.name.as_ref()), + ) .finish() } } diff --git a/backend/library/Cargo.toml b/backend/library/Cargo.toml index 627f99e08..34bc3d849 100644 --- a/backend/library/Cargo.toml +++ b/backend/library/Cargo.toml @@ -25,6 +25,7 @@ reqwest = { version = "0.13.4", features = ["json", "stream", "rustls", "socks", serde.workspace = true serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } strsim = "0.11.1" +thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process", "time", "macros"] } url.workspace = true uuid = { version = "1.26.0", features = ["v4"] } diff --git a/backend/library/src/library/metadata_resolver.rs b/backend/library/src/library/metadata_resolver.rs index fb4ae7366..6b2b76844 100644 --- a/backend/library/src/library/metadata_resolver.rs +++ b/backend/library/src/library/metadata_resolver.rs @@ -51,11 +51,11 @@ impl MetadataResolver { Self { tmdb_client, fallback_to_filename: library_config.is_some_and(|c| c.metadata.fallback_to_filename) } } - // Resolves metadata for a video file using multiple sources (Main entry point for Library Scanner) + /// Resolves metadata for a video file using the available sources. pub async fn resolve(&self, group: &MediaGroup) -> Option { debug!("Resolving metadata for: {group}"); - // Step 1: Classify the file + // Select the representative file and any metadata already attached to it. let (is_movie, Some(file), metadata) = (match group { MediaGroup::Movie { file, metadata } => (true, Some(file), metadata.as_ref()), MediaGroup::Series { show_key: _, episodes } => { @@ -134,16 +134,16 @@ impl MetadataResolver { } } - // TODO series implementation missing for NFO + // TODO: Add NFO support for series. // if classification == MediaClassification::Movie { - // // Step 3: Try to read existing NFO file + // // Prefer metadata from an existing NFO file. // if let Some(metadata) = NfoReader::read_metadata(&file.path).await { // info!("Found NFO metadata for: {}", file.file_path); // return Some(metadata); // } // } - // Step 4: Fallback to filename parsing + // Fall back to filename parsing when configured. if self.fallback_to_filename { if let Some(f) = file { debug!("Using filename-based metadata for: {}", f.file_path); diff --git a/backend/library/src/library/processor.rs b/backend/library/src/library/processor.rs index 2b7644456..9dde40775 100644 --- a/backend/library/src/library/processor.rs +++ b/backend/library/src/library/processor.rs @@ -50,32 +50,12 @@ enum ProcessAction { Unchanged, } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] enum LibraryProcessError { + #[error("{0}")] Resolve(String), - Io(io::Error), -} - -impl fmt::Display for LibraryProcessError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Resolve(message) => f.write_str(message), - Self::Io(err) => write!(f, "{err}"), - } - } -} - -impl std::error::Error for LibraryProcessError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Resolve(_) => None, - Self::Io(err) => Some(err), - } - } -} - -impl From for LibraryProcessError { - fn from(value: io::Error) -> Self { Self::Io(value) } + #[error(transparent)] + Io(#[from] io::Error), } // VOD processor that orchestrates scanning, classification, metadata resolution, and storage diff --git a/backend/media-server/Cargo.toml b/backend/media-server/Cargo.toml index 8789f762d..73606ea9f 100644 --- a/backend/media-server/Cargo.toml +++ b/backend/media-server/Cargo.toml @@ -17,6 +17,7 @@ reqwest = { version = "0.13.4", features = ["json", "stream", "rustls", "socks", serde.workspace = true serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } shared = { workspace = true } +thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "parking_lot", "time", "macros"] } url.workspace = true diff --git a/backend/processing/src/processor/playlist.rs b/backend/processing/src/processor/playlist.rs deleted file mode 100644 index 656caf76d..000000000 --- a/backend/processing/src/processor/playlist.rs +++ /dev/null @@ -1,3973 +0,0 @@ -use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider}; -use crate::{ - fetched_playlist::FetchedPlaylist, - input_cache, - input_cache::ClusterState, - metadata_sink::{MetadataUpdateSink, NoopMetadataSink}, - parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator, TVGuide}, - playlist_watch::{process_group_watch, process_target_groups_watch}, - processor::{ - epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups}, - sort::sort_playlist, - trakt::process_trakt_categories_for_target, - xtream_series::playlist_resolve_series, - xtream_vod::playlist_resolve_vod, - StalkerRefreshMode, - }, -}; -use futures::{FutureExt, StreamExt}; -use indexmap::IndexMap; -use log::{debug, error, info, log_enabled, warn, Level}; -use path_clean::PathClean; -use shared::{ - concat_string, - defaults::{default_as_default, default_probe_delay_secs, default_probe_live_interval}, - error::{get_errors_notify_message, TuliproxError}, - foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}, - model::{ - ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet, InputStats, - InputType, MappingStage, PipelineStats, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, - PlaylistUpdateProgressEvent, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, - TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster, - }, - utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable}, -}; -use std::{ - collections::{HashMap, HashSet}, - future::Future, - path::PathBuf, - sync::{Arc, Weak}, - time::{Duration, Instant}, -}; -use tokio::{ - sync::{watch, Mutex, OwnedRwLockWriteGuard, RwLock}, - task::JoinSet, -}; -use tuliprox_core::{ - model::{ - is_valid, retain_filtered_playlist, AppConfig, CompiledMapping, ConfigFavourites, ConfigInput, - ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg, FilterOutcome, MappingProgram, - ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig, TransformStage, UpdateGuard, - UpdateTask, - }, - utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback}, -}; -use tuliprox_iptv::{ - epg::{CountingEpgSink, EpgFetchRequest, EpgProvider}, - error::ProviderErrorKind, - provider::{ - BatchContainerProvider, M3uProvider, PlaylistFetch, PlaylistFetchRequest, PlaylistProvider, - UnsupportedProvider, XtreamProvider, - }, - xtream, -}; -use tuliprox_repository::{ - load_input_playlist, persist_input_playlist, persist_playlist, CategoryKey, MemoryPlaylistSource, PlaylistSource, - PlaylistStorageState, -}; -use tuliprox_session::ActiveProviderManager; - -const PLAYLIST_UPDATE_MAX_DURATION_SECS: u64 = 3600; -const MAX_CONCURRENT_TARGET_FINALIZERS: usize = 2; - -fn join_arc_strs(values: &[Arc], separator: &str) -> String { - let mut result = String::new(); - for value in values { - if !result.is_empty() { - result.push_str(separator); - } - result.push_str(value.as_ref()); - } - result -} - -fn target_waiting_message(target: &str, input: &str) -> String { - format!("Target '{target}' is waiting for input '{input}'") -} - -fn target_mutated_resources(config: &tuliprox_core::model::Config, target: &ConfigTarget) -> HashSet { - let mut resources = HashSet::new(); - if let Some(path) = tuliprox_repository::get_target_storage_path(config, &target.name) { - resources.insert(path.clean()); - } - for output in &target.output { - match output { - tuliprox_core::model::TargetOutput::M3u(output) => { - if let Some(path) = tuliprox_core::utils::get_file_path( - &config.storage_dir, - output.filename.as_deref().map(PathBuf::from), - ) { - resources.insert(path.clean()); - } - } - tuliprox_core::model::TargetOutput::Strm(output) => { - if let Some(path) = - tuliprox_core::utils::get_file_path(&config.storage_dir, Some(PathBuf::from(&output.directory))) - { - resources.insert(path.clean()); - } - } - tuliprox_core::model::TargetOutput::Xtream(_) | tuliprox_core::model::TargetOutput::HdHomeRun(_) => {} - } - } - resources -} - -fn stalker_checkpoint_message(input: &str) -> String { - format!("Input '{input}': Stalker refresh checkpoint saved; active snapshot remains in service") -} - -fn retain_playlist_items( - source: &mut PlaylistSource, - mut keep: impl FnMut(&PlaylistItem) -> bool, -) -> (Option>, FilterOutcome) { - let mut groups: IndexMap = IndexMap::new(); - let mut outcome = FilterOutcome::default(); - for pli in source.into_items() { - if outcome.record(keep(&pli)) { - let group_title = pli.header.group.clone(); - let cluster = pli.header.xtream_cluster; - let cat_id = pli.header.category_id; - let normalized_group = shared::utils::deunicode_string(&group_title).to_lowercase().intern(); - let key = (cluster, normalized_group); - groups - .entry(key) - .or_insert_with(|| PlaylistGroup { - id: cat_id, - title: group_title, - channels: vec![], - xtream_cluster: cluster, - }) - .channels - .push(pli); - } - } - - let groups = if groups.is_empty() { None } else { Some(groups.into_values().collect()) }; - (groups, outcome) -} - -pub fn apply_filter_to_source(source: &mut PlaylistSource, filter: &Filter) -> Option> { - retain_playlist_items(source, |item| is_valid(item, filter, false)).0 -} - -fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { - let assigned_chnos: HashSet = - new_playlist.iter().flat_map(|g| &g.channels).filter(|c| c.header.chno != 0).map(|c| c.header.chno).collect(); - let mut chno = 1; - for group in new_playlist { - for chan in &mut group.channels { - if chan.header.chno == 0 { - while assigned_chnos.contains(&chno) { - chno += 1; - } - chan.header.chno = chno; - chno += 1; - } - } - } -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub struct RenameOutcome { - pub inspected: usize, - pub changed_items: usize, - pub changed_fields: usize, -} - -#[derive(Debug, Default)] -pub struct PipelineOutcome { - pub filter: Option, - pub rename: Option, - pub mapping: Option, -} - -impl PipelineOutcome { - fn merge(&mut self, other: Self) { - if let Some(value) = other.filter { - let outcome = self.filter.get_or_insert_with(FilterOutcome::default); - outcome.inspected += value.inspected; - outcome.retained += value.retained; - outcome.removed += value.removed; - } - if let Some(value) = other.rename { - let outcome = self.rename.get_or_insert_with(RenameOutcome::default); - outcome.inspected += value.inspected; - outcome.changed_items += value.changed_items; - outcome.changed_fields += value.changed_fields; - } - if let Some(value) = other.mapping { - let outcome = self.mapping.get_or_insert_with(MappingStageOutcome::default); - outcome.inspected += value.inspected; - outcome.matched_rules += value.matched_rules; - outcome.emitted_items += value.emitted_items; - outcome.changed_fields.extend(value.changed_fields); - outcome.diagnostics += value.diagnostics; - outcome.reported_diagnostics += value.reported_diagnostics; - } - } - - fn to_stats(&self) -> PipelineStats { - PipelineStats { - inspected: self.filter.as_ref().map_or(0, |outcome| outcome.inspected), - retained: self.filter.as_ref().map_or(0, |outcome| outcome.retained), - removed: self.filter.as_ref().map_or(0, |outcome| outcome.removed), - renamed_items: self.rename.as_ref().map_or(0, |outcome| outcome.changed_items), - renamed_fields: self.rename.as_ref().map_or(0, |outcome| outcome.changed_fields), - matched_mapping_rules: self.mapping.as_ref().map_or(0, |outcome| outcome.matched_rules), - emitted_items: self.mapping.as_ref().map_or(0, |outcome| outcome.emitted_items), - mapping_diagnostics: self.mapping.as_ref().map_or(0, |outcome| outcome.diagnostics), - } - } -} - -fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) -> usize { - let mut changed_fields = 0; - if let Some(renames) = rename { - if !renames.is_empty() { - let result = pli; - for r in renames { - let value = get_field_value(result, r.field); - let cap = r.pattern.replace_all(&value, &r.new_name); - if log_enabled!(log::Level::Debug) && *value != *cap { - trace_if_enabled!("Renamed {}={value} to {cap}", &r.field); - } - if *value != *cap && set_field_value(result, r.field, cap.as_ref()) { - changed_fields += 1; - } - } - } - } - changed_fields -} - -struct ChannelMappingOutcome { - channel: PlaylistItem, - virtual_items: Vec, - matched_rules: usize, - changed_fields: HashSet, - diagnostics: Vec, -} - -const MAPPING_DIAGNOSTIC_LIMIT: usize = 10; - -#[derive(Debug, Default)] -pub struct MappingStageOutcome { - pub inspected: usize, - pub matched_rules: usize, - pub emitted_items: usize, - pub changed_fields: HashSet, - pub diagnostics: usize, - pub reported_diagnostics: usize, -} - -impl MappingStageOutcome { - fn record(&mut self, mapping_id: &str, outcome: &ChannelMappingOutcome) { - self.inspected += 1; - self.matched_rules += outcome.matched_rules; - self.emitted_items += outcome.virtual_items.len(); - self.changed_fields.extend(outcome.changed_fields.iter().cloned()); - self.diagnostics += outcome.diagnostics.len(); - for diagnostic in &outcome.diagnostics { - if self.reported_diagnostics >= MAPPING_DIAGNOSTIC_LIMIT { - break; - } - warn!("Mapping '{mapping_id}' {diagnostic}"); - self.reported_diagnostics += 1; - } - } -} - -fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome { - let mut matched_rules = 0; - let mut virtual_items = vec![]; - let mut changed_fields = HashSet::new(); - let mut diagnostics = Vec::new(); - if !mapping.rules.is_empty() { - let ref_chan = &mut channel; - let templates = mapping.templates.as_deref(); - for (rule_index, rule) in mapping.rules.iter().enumerate() { - let provider = ValueProvider { pli: ref_chan, match_as_ascii: mapping.match_as_ascii }; - if rule.filter.filter(&provider) { - matched_rules += 1; - let mut accessor = ValueAccessor { - pli: ref_chan, - virtual_items: vec![], - match_as_ascii: mapping.match_as_ascii, - changed_fields: vec![], - }; - let outcome = match &rule.program { - MappingProgram::Script(script) => script.eval(&mut accessor, templates), - }; - changed_fields.extend(outcome.changed_fields.iter().cloned()); - for diagnostic in outcome.diagnostics { - let rule_label = rule.name.as_deref().map_or_else(|| (rule_index + 1).to_string(), str::to_string); - diagnostics.push(format!( - "rule '{rule_label}' failed for channel '{}' at statement {}: {}", - accessor.pli.header.name, - diagnostic.statement + 1, - diagnostic.message - )); - } - virtual_items.extend(accessor.virtual_items.into_iter().map(|(_, pli)| pli)); - } - } - } - ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics } -} - -fn map_playlist_at_stage( - source: &mut PlaylistSource, - target: &ConfigTarget, - stage: MappingStage, - duplicates: Option<&mut HashSet>, -) -> Option> { - if !has_mapping_stage(target, stage) { - return None; - } - let items = source.into_items().collect::>(); - let (mapped_items, _outcome) = map_items_at_stage(items, target, stage, duplicates)?; - Some(group_mapped_items(mapped_items)) -} - -fn has_mapping_stage(target: &ConfigTarget, stage: MappingStage) -> bool { - target.mapping.load().as_ref().is_some_and(|mappings| !mappings.for_stage(stage).is_empty()) -} - -fn map_items_at_stage( - mut mapped_items: Vec, - target: &ConfigTarget, - stage: MappingStage, - duplicates: Option<&mut HashSet>, -) -> Option<(Vec, MappingStageOutcome)> { - let mapping_binding = target.mapping.load(); - let mappings = mapping_binding.as_ref()?; - let valid_mappings = mappings.for_stage(stage); - if valid_mappings.is_empty() { - return None; - } - let original_ids = if duplicates.is_some() { - Some(mapped_items.iter().map(|item| *item.header.get_uuid()).collect::>()) - } else { - None - }; - let mut stage_outcome = MappingStageOutcome::default(); - for mapping in valid_mappings { - let mut next_items = Vec::with_capacity(mapped_items.len()); - for channel in mapped_items { - let outcome = map_channel(channel, mapping); - stage_outcome.record(&mapping.id, &outcome); - next_items.push(outcome.channel); - next_items.extend(outcome.virtual_items); - } - mapped_items = next_items; - } - debug!( - "Mapping stage {stage:?}: inspected={}, matched_rules={}, emitted={}, changed_fields={}, diagnostics={}, suppressed_diagnostics={}", - stage_outcome.inspected, - stage_outcome.matched_rules, - stage_outcome.emitted_items, - stage_outcome.changed_fields.len(), - stage_outcome.diagnostics, - stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics) - ); - let suppressed = stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics); - if suppressed > 0 { - warn!("Mapping stage {stage:?} suppressed {suppressed} additional diagnostics"); - } - if let (Some(original_ids), Some(duplicates)) = (original_ids, duplicates) { - mapped_items.retain(|item| { - let uuid = *item.header.get_uuid(); - original_ids.contains(&uuid) || duplicates.insert(uuid) - }); - } - Some((mapped_items, stage_outcome)) -} - -fn group_mapped_items(items: Vec) -> Vec { - let mut groups: IndexMap = IndexMap::new(); - let mut group_id = 0; - for channel in items { - let group_title = channel.header.group.clone(); - let cluster = channel.header.xtream_cluster; - groups - .entry((cluster, group_title.clone())) - .or_insert_with(|| { - group_id += 1; - PlaylistGroup { id: group_id, title: group_title, channels: Vec::new(), xtream_cluster: cluster } - }) - .channels - .push(channel); - } - groups.into_values().collect() -} - -fn map_playlist_counter(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) { - if let Some(guard) = &*target.mapping.load() { - for mapping in &guard.all { - for counter in &mapping.counters { - // fresh per target/call. No shared atomic, no cross-refresh carry-over. - let mut current = counter.start; - for plg in &mut *playlist { - for channel in &mut plg.channels { - let provider = ValueProvider { pli: channel, match_as_ascii: mapping.match_as_ascii }; - if counter.filter.filter(&provider) { - let cntval = current; - current += 1; - let padded_cntval = if counter.padding > 0 { - format!("{:0width$}", cntval, width = counter.padding as usize) - } else { - cntval.to_string() - }; - let new_value = if counter.modifier == CounterModifier::Assign { - padded_cntval - } else { - let value = channel - .header - .get(counter.field) - .map_or_else(String::new, |field_value| field_value.as_cow().into_owned()); - if counter.modifier == CounterModifier::Suffix { - format!("{value}{}{padded_cntval}", counter.concat) - } else { - format!("{padded_cntval}{}{value}", counter.concat) - } - }; - channel.header.set(counter.field, new_value.as_str()); - } - } - } - } - } - } -} - -// Inputs disabled in the config are always disabled. -// Command-line targets can only restrict enabled inputs, never enable them. -fn is_input_enabled(input: &ConfigInput, user_targets: &ProcessTargets) -> bool { - input.enabled && (!user_targets.enabled || user_targets.has_input(input.id)) -} - -fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bool { - (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) -} - -async fn with_sequential_group( - file_locks: &tuliprox_core::utils::FileLockManager, - group: Option, - process_parallel: bool, - future: impl std::future::Future, -) -> T { - let _guard = if process_parallel { - if let Some(group) = group { - Some(file_locks.write_lock_str(&format!("sequential_group:{group}")).await) - } else { - None - } - } else { - None - }; - future.await -} - -struct PlaylistDownloadResult { - pub downloaded_playlist: Vec, - pub download_err: Vec, - pub was_cached: bool, - pub persisted: bool, - pub partial: bool, -} - -impl PlaylistDownloadResult { - pub fn new( - downloaded_playlist: Vec, - download_err: Vec, - was_cached: bool, - persisted: bool, - ) -> Self { - Self { downloaded_playlist, download_err, was_cached, persisted, partial: false } - } - - fn with_partial(mut self, partial: bool) -> Self { - self.partial = partial; - self - } -} - -fn collect_effective_skip_clusters(input: &ConfigInput) -> Vec { - if !input.input_type.is_xtream() { - return vec![]; - } - xtream::get_skip_cluster(input) -} - -fn filter_skipped_clusters_from_source(source: PlaylistSource, input: &ConfigInput) -> PlaylistSource { - let skip_clusters = collect_effective_skip_clusters(input); - if skip_clusters.is_empty() { - return source; - } - - let skip_set: HashSet = skip_clusters.into_iter().collect(); - PlaylistSource::filtered(source, skip_set) -} - -fn cluster_selected(cluster: XtreamCluster, clusters: ClusterFlags) -> bool { - match cluster { - XtreamCluster::Live => clusters.contains(ClusterFlags::Live), - XtreamCluster::Video => clusters.contains(ClusterFlags::Vod), - XtreamCluster::Series => clusters.contains(ClusterFlags::Series), - } -} - -fn apply_staged_overlay_groups( - provider_name: &Arc, - clusters: ClusterFlags, - provider_groups: Vec, - staged_groups: Vec, -) -> Vec { - let mut groups: Vec = - provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)).collect(); - - groups.extend(staged_groups.into_iter().filter(|group| cluster_selected(group.xtream_cluster, clusters)).map( - |mut group| { - for item in &mut group.channels { - item.header.input_name = Arc::clone(provider_name); - } - group - }, - )); - - groups -} - -fn should_apply_staged_overlay(download_result: &PlaylistDownloadResult) -> bool { !download_result.was_cached } - -#[allow(clippy::too_many_lines)] -async fn playlist_download_from_input( - client: &reqwest::Client, - app_config: &Arc, - events: &E, - input: &ConfigInput, - stalker_refresh_mode: StalkerRefreshMode, -) -> PlaylistDownloadResult { - let config = &*app_config.config.load(); - let storage_dir = &config.storage_dir; - - // Check Status - let storage_path = input_cache::resolve_input_storage_path(storage_dir, &input.name).await; - let mut status = input_cache::load_input_status(&storage_path); - let cache_duration = input.cache_duration_seconds; - - // Ensure data directory exists - match tokio::fs::try_exists(&storage_path).await { - Ok(false) => { - if let Err(err) = tokio::fs::create_dir_all(&storage_path).await { - warn!("Failed to create input storage directory '{}': {err}", storage_path.display()); - } - } - Err(err) => { - warn!("Failed to check existence of input storage directory '{}': {err}", storage_path.display()); - } - Ok(true) => {} - } - - let download_input_type = input.get_download_input_type(); - // Use per-cluster cache for effective Xtream downloads. - let use_per_cluster_cache = download_input_type.is_xtream(); - - let mut xtream_clusters_to_download = Vec::new(); - let fully_cached = if use_per_cluster_cache { - let skip_cluster = collect_effective_skip_clusters(input); - let xtream_cache_candidates = xtream::requested_clusters(None, &skip_cluster); - - for cluster in xtream_cache_candidates { - if !input_cache::is_cache_valid(&status, cluster.as_ref(), cache_duration) { - xtream_clusters_to_download.push(cluster); - } - } - - xtream_clusters_to_download.is_empty() - } else { - input_cache::is_cache_valid(&status, "default", cache_duration) - }; - - if fully_cached { - return PlaylistDownloadResult::new(vec![], vec![], true, false); - } - - let request = PlaylistFetchRequest { - app_config, - config: &app_config.config.load(), - client, - input, - xtream_clusters: Some(xtream_clusters_to_download.as_slice()), - }; - - // Each arm builds the provider its input type needs and awaits it in place: the - // provider types share no supertype, and building one is free, so this stays a match - // and stays statically dispatched. What changed is the result - one named - // `PlaylistFetch` instead of a six-element tuple assembled by position. - let fetch = match download_input_type { - InputType::M3u => M3uProvider.fetch(&request).await, - InputType::Xtream => XtreamProvider::new(events).fetch(&request).await, - InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch => { - BatchContainerProvider.fetch(&request).await - } - InputType::Stalker => { - StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await - } - InputType::Library => LibraryProvider.fetch(&request).await, - InputType::Plex => PlexProvider.fetch(&request).await, - InputType::Emby | InputType::Jellyfin => { - UnsupportedProvider::new( - "media-server", - format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name), - ) - .fetch(&request) - .await - } - InputType::Staged => { - UnsupportedProvider::new( - "staged", - format!("staged input '{}' was not resolved against a parent input", input.name), - ) - .fetch(&request) - .await - } - }; - // `ProviderErrorKind` has always been able to answer "is this worth - // retrying, and does it need a human" - `needs_operator()` is exactly that - // question - and nothing consumed the answer. Every fetch failure was - // counted, logged and treated identically. - if let Some(kind) = fetch.error_kind() { - let worst = fetch - .errors - .iter() - .max_by_key(|error| ProviderErrorKind::of_tuliprox(error)) - .map(|error| sanitize_sensitive_info(&error.to_string()).into_owned()); - events.emit(EventMessage::ProviderFetchFailed(ProviderFetchFailure { - input: sanitize_sensitive_info(&input.name).into_owned().into(), - provider: download_input_type.to_string().into(), - kind: kind.into(), - error_count: fetch.errors.len(), - message: worst, - retryable: kind.is_retryable(), - needs_operator: kind.needs_operator(), - partial: fetch.partial, - })); - } - - let PlaylistFetch { groups: playlist, errors, persisted, partial } = fetch; - - // Update Status - let save_status; - if partial { - input_cache::update_cluster_status(&mut status, "default", ClusterState::Failed); - save_status = true; - } else if errors.is_empty() { - if use_per_cluster_cache { - for cluster in &xtream_clusters_to_download { - input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Ok); - } - save_status = !xtream_clusters_to_download.is_empty(); - } else { - input_cache::update_cluster_status(&mut status, "default", ClusterState::Ok); - save_status = true; - } - } else if use_per_cluster_cache { - for cluster in &xtream_clusters_to_download { - input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Failed); - } - save_status = !xtream_clusters_to_download.is_empty(); - } else { - input_cache::update_cluster_status(&mut status, "default", ClusterState::Failed); - save_status = true; - } - - if save_status { - input_cache::save_input_status(&storage_path, &status); - } - - PlaylistDownloadResult::new(playlist, errors, false, persisted).with_partial(partial) -} - -#[derive(Clone, Copy, Eq, PartialEq)] -enum InputJobState { - Ready, - Pending, - Failed, -} - -struct InputJobResult { - index: usize, - input_name: Arc, - state: InputJobState, - source: Option, - epg: Option, - stat: InputStats, - errors: Vec, -} - -async fn process_input_job( - index: usize, - ctx: &PlaylistProcessingContext, - input: &Arc, - process_parallel: bool, -) -> InputJobResult { - with_sequential_group( - &ctx.config.file_locks, - input.sequential_group, - process_parallel, - process_input_job_inner(index, ctx, input), - ) - .await -} - -async fn process_input_job_inner( - index: usize, - ctx: &PlaylistProcessingContext, - input: &Arc, -) -> InputJobResult { - let start_time = Instant::now(); - let input_type = input.get_download_input_type(); - let broadcast_step = create_broadcast_callback(&ctx.events); - broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); - - let (mut errors, mut source, storage_error, partial) = download_input(ctx, input, false).await; - let storage_failed = storage_error.is_some(); - if let Some(err) = storage_error { - broadcast_step("Playlist download", &format!("Failed to persist/load input '{}' playlist", input.name)); - error!("Failed to persist input playlist {}", input.name); - errors.push(err); - } - let epg = if input_type == InputType::Library || partial || storage_failed { - None - } else { - download_input_epg(ctx, input, &mut errors).await - }; - let group_count = source.get_group_count(); - let channel_count = source.get_channel_count(); - let state = if partial { - InputJobState::Pending - } else if storage_failed || source.is_empty() { - if source.is_empty() { - broadcast_step("Playlist download", &format!("Input '{}' playlist is empty", input.name)); - errors.push(TuliproxError::RepositoryPlaylist(format!("Source is empty {}", input.name))); - } - InputJobState::Failed - } else { - InputJobState::Ready - }; - let stat = create_input_stat( - group_count, - channel_count, - errors.len(), - input_type, - &input.name, - start_time.elapsed().as_secs(), - ); - - InputJobResult { - index, - input_name: input.name.clone(), - state, - source: (state == InputJobState::Ready).then_some(source), - epg, - stat, - errors, - } -} - -fn panicked_input_job(index: usize, input: &ConfigInput) -> InputJobResult { - let error = TuliproxError::RepositoryPlaylist(format!("Input '{}' processing panicked", input.name)); - InputJobResult { - index, - input_name: input.name.clone(), - state: InputJobState::Failed, - source: None, - epg: None, - stat: create_input_stat(0, 0, 1, input.get_download_input_type(), &input.name, 0), - errors: vec![error], - } -} - -struct TargetJobResult { - index: usize, - name: String, - result: Result<(), Vec>, - errors: Vec, - processing: PipelineStats, -} - -fn collect_target_task_result( - result: Result, - results: &mut Vec, - errors: &mut Vec, -) { - match result { - Ok(result) => results.push(result), - Err(err) => errors.push(TuliproxError::RepositoryPlaylist(format!("Target finalization task failed: {err}"))), - } -} - -async fn wait_for_target_finalizer_slot( - tasks: &mut JoinSet, - results: &mut Vec, - errors: &mut Vec, -) { - if tasks.len() >= MAX_CONCURRENT_TARGET_FINALIZERS { - if let Some(result) = tasks.join_next().await { - collect_target_task_result(result, results, errors); - } - } -} - -#[allow(clippy::too_many_lines)] -async fn process_targets( - ctx: &Arc>, - playlists: &mut [FetchedPlaylist<'_>], - targets: &[&Arc], - input_stats: &mut HashMap, InputStats>, - errors: &mut Vec, - process_parallel: bool, -) -> Vec { - if !process_parallel { - let mut target_stats = Vec::with_capacity(targets.len()); - for (index, target) in targets.iter().enumerate() { - let consume_input_source = index + 1 == targets.len(); - let result = - prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, consume_input_source).await; - match result { - Ok(prepared) => { - let processing = prepared.processing.clone(); - let (result, mut finalization_errors) = finalize_prepared_target(Arc::clone(ctx), prepared).await; - errors.append(&mut finalization_errors); - match result { - Ok(()) => target_stats.push(TargetStats::success_with_processing(&target.name, processing)), - Err(mut target_errors) => { - target_stats.push(TargetStats::failure_with_processing(&target.name, processing)); - errors.append(&mut target_errors); - } - } - } - Err(mut target_errors) => { - target_stats.push(TargetStats::failure(&target.name)); - errors.append(&mut target_errors); - } - } - } - return target_stats; - } - - let resources = { - let config = ctx.config.config.load(); - targets.iter().map(|target| target_mutated_resources(&config, target)).collect::>() - }; - let mut completion_receivers: Vec> = Vec::with_capacity(targets.len()); - let mut tasks = JoinSet::new(); - let mut results = Vec::with_capacity(targets.len()); - - for (index, target) in targets.iter().enumerate() { - wait_for_target_finalizer_slot(&mut tasks, &mut results, errors).await; - let predecessors = resources[..index] - .iter() - .zip(&completion_receivers) - .filter(|(earlier, _)| !earlier.is_disjoint(&resources[index])) - .map(|(_, receiver)| receiver.clone()) - .collect::>(); - let (completion, receiver) = watch::channel(false); - completion_receivers.push(receiver); - - match prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, false).await { - Ok(prepared) => { - let processing = prepared.processing.clone(); - let task_ctx = Arc::clone(ctx); - let target_name = target.name.clone(); - tasks.spawn(async move { - for mut predecessor in predecessors { - if !*predecessor.borrow() { - let _ = predecessor.changed().await; - } - } - let finalized = - std::panic::AssertUnwindSafe(finalize_prepared_target(task_ctx, prepared)).catch_unwind().await; - completion.send_replace(true); - match finalized { - Ok((result, errors)) => { - TargetJobResult { index, name: target_name, result, errors, processing } - } - Err(_) => TargetJobResult { - index, - name: target_name.clone(), - result: Err(vec![TuliproxError::RepositoryPlaylist(format!( - "Target '{target_name}' finalization panicked" - ))]), - errors: Vec::new(), - processing, - }, - } - }); - } - Err(target_errors) => { - completion.send_replace(true); - results.push(TargetJobResult { - index, - name: target.name.clone(), - result: Err(target_errors), - errors: Vec::new(), - processing: PipelineStats::default(), - }); - } - } - } - - while let Some(result) = tasks.join_next().await { - collect_target_task_result(result, &mut results, errors); - } - results.sort_by_key(|result| result.index); - - let mut target_stats = Vec::with_capacity(results.len()); - for mut target_result in results { - errors.append(&mut target_result.errors); - match target_result.result { - Ok(()) => { - target_stats.push(TargetStats::success_with_processing(&target_result.name, target_result.processing)); - } - Err(mut target_errors) => { - target_stats.push(TargetStats::failure_with_processing(&target_result.name, target_result.processing)); - errors.append(&mut target_errors); - } - } - } - target_stats -} - -#[allow(clippy::too_many_lines)] -async fn process_source( - source_idx: usize, - ctx: Arc>, -) -> (Vec, Vec, Vec) { - log_memory_snapshot(format!("source[{source_idx}] start").as_str()); - let sources = ctx.config.sources.load(); - let mut errors = vec![]; - let mut input_stats = HashMap::, InputStats>::new(); - let mut target_stats = Vec::::new(); - if let Some(source) = sources.get_source_at(source_idx) { - let mut source_playlists = Vec::with_capacity(source.inputs.len()); - let broadcast_step = create_broadcast_callback(&ctx.events); - let process_parallel = ctx.config.config.load().process_parallel; - let mut disabled_inputs: Vec> = vec![]; - let mut enabled_inputs = Vec::with_capacity(source.inputs.len()); - for (index, input_name) in source.inputs.iter().enumerate() { - let Some(input) = sources.get_input_by_name(input_name) else { - error!("Input {input_name} referenced by source {source_idx} does not exist"); - continue; - }; - if is_input_enabled(input, &ctx.user_targets) { - enabled_inputs.push((index, input)); - } else { - disabled_inputs.push(input.name.clone()); - } - } - - let source_downloaded = !enabled_inputs.is_empty(); - let mut job_results = Vec::with_capacity(enabled_inputs.len()); - if process_parallel { - let mut jobs = futures::stream::FuturesUnordered::new(); - for &(index, input) in &enabled_inputs { - let job = std::panic::AssertUnwindSafe(process_input_job(index, &ctx, input, true)).catch_unwind(); - jobs.push(async move { - match job.await { - Ok(result) => result, - Err(_) => panicked_input_job(index, input), - } - }); - } - while let Some(result) = jobs.next().await { - job_results.push(result); - } - } else { - for &(index, input) in &enabled_inputs { - job_results.push(process_input_job(index, &ctx, input, false).await); - } - } - job_results.sort_by_key(|result| result.index); - - let mut blockers = Vec::new(); - for mut result in job_results { - errors.append(&mut result.errors); - input_stats.insert(result.input_name.clone(), result.stat); - if result.state == InputJobState::Ready { - if let (Some(input), Some(source)) = - (sources.get_input_by_name(&result.input_name), result.source.take()) - { - source_playlists.push(FetchedPlaylist { input, source, epg: result.epg }); - } - } else { - blockers.push(result.input_name); - } - } - - if !disabled_inputs.is_empty() && !source_downloaded { - warn!( - "Source at index {source_idx} has no enabled inputs for the given targets. Disabled: {}", - join_arc_strs(&disabled_inputs, ", ") - ); - } - if source_downloaded { - if !blockers.is_empty() { - for target in source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)) { - for input_name in &blockers { - broadcast_step("Playlist download", &target_waiting_message(&target.name, input_name)); - } - } - } else if source_playlists.is_empty() { - debug!("Source at index {source_idx} is empty"); - errors.push(TuliproxError::RepositoryPlaylist(format!( - "Source at index {source_idx} is empty: {}", - join_arc_strs(&source.inputs, ", ") - ))); - } else { - debug_if_enabled!( - "Source has {} groups", - source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::() - ); - let enabled_targets: Vec<_> = - source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)).collect(); - target_stats = process_targets( - &ctx, - &mut source_playlists, - &enabled_targets, - &mut input_stats, - &mut errors, - process_parallel, - ) - .await; - } - } - } - log_memory_snapshot(format!("source[{source_idx}] end").as_str()); - let ordered_input_stats = sources - .get_source_at(source_idx) - .map_or_else(Vec::new, |source| source.inputs.iter().filter_map(|name| input_stats.remove(name)).collect()); - (ordered_input_stats, target_stats, errors) -} - -async fn download_input_epg( - ctx: &PlaylistProcessingContext, - input: &Arc, - error_list: &mut Vec, -) -> Option { - // A failed playlist download makes the EPG moot: the channels it would annotate are - // not there. - if !error_list.is_empty() { - return None; - } - let provider = XmltvEpgProvider::new(ctx); - // The XMLTV path produces documents, not programme records, so nothing reaches the - // sink. It is here because the same call answers for a record-streaming provider. - let mut discarded = CountingEpgSink::new(); - let outcome = provider.fetch(&EpgFetchRequest::new(input), &mut discarded).await; - error_list.extend(provider.take_errors()); - match outcome { - Ok(outcome) => outcome.into_guide(), - Err(err) => { - error_list.push(err); - None - } - } -} - -/// `invalidate_input_cache_status` performs a non-atomic file I/O sequence -/// (`input_cache::load_input_status` + `input_cache::save_input_status`). -/// Call this only while holding the per-input lock from -/// `PlaylistProcessingContext::get_input_lock` (as done in `download_input`). -async fn invalidate_input_cache_status( - ctx: &PlaylistProcessingContext, - input: &ConfigInput, -) { - let storage_dir = { ctx.config.config.load().storage_dir.clone() }; - let storage_path = input_cache::resolve_input_storage_path(&storage_dir, &input.name).await; - let mut status = input_cache::load_input_status(&storage_path); - if !status.clusters.is_empty() { - status.clusters.clear(); - input_cache::save_input_status(&storage_path, &status); - } -} - -async fn load_cached_input_playlist( - ctx: &PlaylistProcessingContext, - input: &Arc, -) -> (PlaylistSource, Option) { - match load_input_playlist(&ctx.config, input, None).await { - Ok(pl_source) => (pl_source, None), - Err(err) => (MemoryPlaylistSource::default().into_source(), Some(err)), - } -} - -#[allow(clippy::too_many_lines)] -async fn download_input( - ctx: &PlaylistProcessingContext, - input: &Arc, - allow_staged_input: bool, -) -> (Vec, PlaylistSource, Option, bool) { - if input.staged.is_some() && !allow_staged_input { - return (vec![], MemoryPlaylistSource::default().into_source(), None, false); - } - - let staged_overlay = if input.staged.is_none() { - let sources = ctx.config.sources.load(); - sources.get_staged_input_for_provider(&input.name).cloned() - } else { - None - }; - - // Coordination Logic - let need_download = !ctx.is_input_downloaded(&input.name).await; - // Keep this lock for the whole critical section (download + persist/load + mark processed) - // so parallel sources sharing the same input cannot observe a half-written state. - let mut input_lock = if need_download { Some(ctx.get_input_lock(&input.name).await) } else { None }; - let mut mark_as_processed = false; - - let mut playlist_download_result = if need_download { - // Check again after lock - let already_processed = ctx.is_input_downloaded(&input.name).await; - - if already_processed { - // Use empty results, will load from disk below - PlaylistDownloadResult::new(vec![], vec![], true, false) - } else if ctx.pre_processed_inputs.as_ref().is_some_and(|s| s.contains(&input.name)) { - // Input was already processed in a prior session; skip download and load from disk. - // Mark only after load succeeds (or fails) to avoid exposing a half-ready state. - mark_as_processed = true; - PlaylistDownloadResult::new(vec![], vec![], true, false) - } else { - mark_as_processed = true; - playlist_download_from_input(&ctx.client, &ctx.config, &ctx.events, input, ctx.stalker_refresh_mode).await - } - } else { - PlaylistDownloadResult::new(vec![], vec![], true, false) - }; - - let mut preloaded_playlist: Option<(PlaylistSource, Option)> = None; - if playlist_download_result.was_cached { - let (cached_playlist, cached_error) = load_cached_input_playlist(ctx, input).await; - // Defensive fallback: if cache metadata says "valid" but persisted data is unreadable, - // retry once before forcing a refresh. - let must_force_refresh = cached_error.is_some(); - if must_force_refresh { - warn!("Input '{}' cache hit produced unreadable playlist; retrying cached load once", input.name); - let (retry_playlist, retry_error) = load_cached_input_playlist(ctx, input).await; - if retry_error.is_none() { - preloaded_playlist = Some((retry_playlist, None)); - } else { - if input_lock.is_none() { - input_lock = Some(ctx.get_input_lock(&input.name).await); - } - // Re-check immediately after locking to avoid duplicate refreshes when another worker - // repaired the cache between our earlier retry and lock acquisition. - let (locked_retry_playlist, locked_retry_error) = load_cached_input_playlist(ctx, input).await; - if locked_retry_error.is_none() { - warn!("Input '{}' cache became readable after lock re-check; skipping refresh", input.name); - preloaded_playlist = Some((locked_retry_playlist, None)); - } else { - warn!( - "Input '{}' cached playlist remained unreadable after retry and lock re-check; invalidating cache and forcing refresh", - input.name - ); - invalidate_input_cache_status(ctx, input).await; - playlist_download_result = playlist_download_from_input( - &ctx.client, - &ctx.config, - &ctx.events, - input, - ctx.stalker_refresh_mode, - ) - .await; - } - } - } else { - preloaded_playlist = Some((cached_playlist, None)); - } - } - if playlist_download_result.partial { - ctx.partial_refresh.store(true, std::sync::atomic::Ordering::Release); - ctx.events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: input.name.to_string(), - message: stalker_checkpoint_message(&input.name), - })); - } - let apply_staged_overlay = should_apply_staged_overlay(&playlist_download_result); - - let (mut playlist, mut error) = if let Some(preloaded) = preloaded_playlist { - preloaded - } else if playlist_download_result.was_cached || playlist_download_result.persisted { - match load_input_playlist(&ctx.config, input, None).await { - Ok(pl_source) => (pl_source, None), - Err(e) => (MemoryPlaylistSource::default().into_source(), Some(e)), - } - } else { - debug!("Persisting input '{}' playlist", input.name); - let (pl, err) = persist_input_playlist(&ctx.config, input, playlist_download_result.downloaded_playlist).await; - (MemoryPlaylistSource::new(pl).into_source(), err) - }; - - playlist = filter_skipped_clusters_from_source(playlist, input); - - if let Some(staged_input) = staged_overlay.filter(|_| apply_staged_overlay) { - let clusters = staged_input.staged.as_ref().map_or_else(ClusterFlags::all, |staged| staged.clusters); - let (mut staged_download_err, mut staged_playlist, staged_error, staged_partial) = - Box::pin(download_input(ctx, &staged_input, true)).await; - playlist_download_result.partial |= staged_partial; - playlist_download_result.download_err.append(&mut staged_download_err); - if let Some(staged_error) = staged_error { - playlist_download_result.download_err.push(staged_error); - } else { - let provider_groups = playlist.take_groups(); - let staged_groups = staged_playlist.take_groups(); - let merged_groups = apply_staged_overlay_groups(&input.name, clusters, provider_groups, staged_groups); - let (merged_playlist, persist_error) = persist_input_playlist(&ctx.config, input, merged_groups).await; - playlist = MemoryPlaylistSource::new(merged_playlist).into_source(); - if error.is_none() { - error = persist_error; - } else if let Some(persist_error) = persist_error { - playlist_download_result.download_err.push(persist_error); - } - } - } - - if mark_as_processed && !playlist_download_result.partial && error.is_none() && !playlist.is_empty() { - // Mark after persist/load so other workers only see this input as ready when data is usable. - ctx.mark_input_downloaded(input.name.clone()).await; - } - - // Explicitly release per-input lock after load/persist/mark steps are completed. - drop(input_lock); - - (playlist_download_result.download_err, playlist, error, playlist_download_result.partial) -} - -fn create_broadcast_callback(events: &E) -> StepMeasureCallback { - let events = events.clone(); - Box::new(move |context: &str, msg: &str| { - events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: context.to_owned(), - message: msg.to_owned(), - })); - }) -} - -fn create_input_stat( - group_count: usize, - channel_count: usize, - error_count: usize, - input_type: InputType, - input_name: &str, - secs_took: u64, -) -> InputStats { - InputStats { - name: input_name.to_string(), - input_type, - error_count, - raw_stats: PlaylistStats { group_count, channel_count }, - processed_stats: PlaylistStats { group_count: 0, channel_count: 0 }, - secs_took, - } -} - -pub struct PlaylistProcessingContext { - pub client: reqwest::Client, - pub config: Arc, - pub user_targets: Arc, - pub events: E, - pub playlist_state: Option>, - /// Reverse-proxy header suppression, carried from the composition root. - /// - /// Nothing in the pipeline reads this today. It became visible when - /// `load_input_playlist` stopped taking the whole context, and it is left in - /// place rather than deleted because the plumbing exists in the API layer - /// and in `exec_processing`'s signature: a configured value that is accepted - /// and ignored is a behaviour question, not a refactoring one. - #[allow(dead_code)] - pub disabled_headers: Option, - - // Coordination - pub processed_inputs: Arc>>>, - #[allow(clippy::type_complexity)] - pub input_locks: Arc, Weak>>>>, - - // New field for STRM probes & background updates - pub provider_manager: Option>, - pub metadata_manager: Option>, - pub pre_processed_inputs: Option>>>, - pub stalker_refresh_mode: StalkerRefreshMode, - pub partial_refresh: Arc, -} - -// Written out rather than derived: `#[derive(Clone)]` would demand `M: Clone`, -// but the sink is held behind an `Arc` and is cloneable whatever `M` is. -impl Clone for PlaylistProcessingContext { - fn clone(&self) -> Self { - Self { - client: self.client.clone(), - config: Arc::clone(&self.config), - user_targets: Arc::clone(&self.user_targets), - events: self.events.clone(), - playlist_state: self.playlist_state.clone(), - disabled_headers: self.disabled_headers.clone(), - processed_inputs: Arc::clone(&self.processed_inputs), - input_locks: Arc::clone(&self.input_locks), - provider_manager: self.provider_manager.clone(), - metadata_manager: self.metadata_manager.clone(), - pre_processed_inputs: self.pre_processed_inputs.clone(), - stalker_refresh_mode: self.stalker_refresh_mode, - partial_refresh: Arc::clone(&self.partial_refresh), - } - } -} - -impl PlaylistProcessingContext { - pub async fn is_input_downloaded(&self, input_name: &str) -> bool { - let processed = self.processed_inputs.lock().await; - processed.contains(input_name) - } - pub async fn mark_input_downloaded(&self, input_name: Arc) -> bool { - let mut processed = self.processed_inputs.lock().await; - processed.insert(input_name) - } - - pub async fn get_input_lock(&self, input_name: &Arc) -> OwnedRwLockWriteGuard<()> { - let mut locks = self.input_locks.lock().await; - // Try to upgrade the existing weak reference - let lock = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| { - let new_lock = Arc::new(RwLock::new(())); - locks.insert(input_name.clone(), Arc::downgrade(&new_lock)); - new_lock - }); - - // Clean up stale references periodically - locks.retain(|_, weak| weak.strong_count() > 0); - - drop(locks); // Release mutex before awaiting write lock - lock.write_owned().await - } -} - -async fn process_sources( - processing_ctx: &PlaylistProcessingContext, -) -> (Vec, Vec) { - let mut async_tasks = JoinSet::new(); - let sources = processing_ctx.config.sources.load(); - let process_parallel = processing_ctx.config.config.load().process_parallel; - if process_parallel && log_enabled!(Level::Debug) { - debug!("Parallel processing enabled"); - } - - let mut source_results = Vec::new(); - let mut errors = Vec::new(); - let mut processed_any = false; - - for (index, source) in sources.sources.iter().enumerate() { - if !source.should_process_for_user_targets(&processing_ctx.user_targets) { - continue; - } - - // We're using the file lock this way on purpose - let source_lock_path = PathBuf::from(concat_string!("source_", &index.to_string())); - let Ok(update_lock) = processing_ctx.config.file_locks.try_write_lock(&source_lock_path).await else { - warn!( - "The update operation for the source at index {index} was skipped because an update is already in progress." - ); - continue; - }; - - let ctx = Arc::new(processing_ctx.clone()); - - processed_any = true; - if process_parallel { - async_tasks.spawn(async move { - let _update_lock = update_lock; - (index, process_source(index, ctx).await) - }); - } else { - source_results.push((index, process_source(index, ctx).await)); - drop(update_lock); - } - } - if !processed_any { - warn!( - "No sources were processed for the given targets. Check that:\n\ - - Sources have enabled targets matching your target selection\n\ - - CLI -t filter or schedule.targets are correct\n\ - - No playlist lock is blocking updates" - ); - } - while let Some(result) = async_tasks.join_next().await { - match result { - Ok(result) => source_results.push(result), - Err(err) => { - error!("Playlist processing task failed: {err:?}"); - errors - .push(TuliproxError::RepositoryPlaylist(format!("Playlist source processing task failed: {err}"))); - } - } - } - - source_results.sort_by_key(|(index, _)| *index); - let mut stats = Vec::with_capacity(source_results.len()); - for (_, (input_stats, target_stats, mut source_errors)) in source_results { - errors.append(&mut source_errors); - if let Some(source_stats) = SourceStats::try_new(input_stats, target_stats) { - stats.push(source_stats); - } - } - (stats, errors) -} - -pub type ProcessingPipe = Vec; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FinalizationStage { - Merge, - Deduplicate, - Sort, - AssignChannelNumbers, - AssignCounters, -} - -const FINALIZATION_ORDER: [FinalizationStage; 5] = [ - FinalizationStage::Merge, - FinalizationStage::Deduplicate, - FinalizationStage::Sort, - FinalizationStage::AssignChannelNumbers, - FinalizationStage::AssignCounters, -]; - -fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { target.execution_plan.transform_stages.clone() } - -#[derive(Clone, Copy)] -enum GroupingPolicy { - NormalizedCategory, - ExactCategory, - ExactSequential, -} - -struct TransformBuffer { - items: Vec, - grouping: GroupingPolicy, -} - -impl TransformBuffer { - fn new(items: Vec) -> Self { Self { items, grouping: GroupingPolicy::ExactCategory } } - - fn apply_filter(&mut self, target: &ConfigTarget) -> FilterOutcome { - let mut outcome = FilterOutcome::default(); - self.items.retain(|item| outcome.record(target.filter(&ValueProvider { pli: item, match_as_ascii: false }))); - self.normalize_filter_grouping(); - outcome - } - - fn normalize_filter_grouping(&mut self) { - self.grouping = GroupingPolicy::NormalizedCategory; - self.reorder_for_grouping(); - } - - fn apply_rename(&mut self, target: &ConfigTarget) -> Option { - let renames = target.rename.as_ref().filter(|renames| !renames.is_empty())?; - let mut outcome = RenameOutcome::default(); - for item in &mut self.items { - outcome.inspected += 1; - let changed_fields = exec_rename(item, Some(renames)); - outcome.changed_fields += changed_fields; - outcome.changed_items += usize::from(changed_fields > 0); - } - self.grouping = GroupingPolicy::ExactCategory; - self.reorder_for_grouping(); - Some(outcome) - } - - fn apply_mapping(&mut self, target: &ConfigTarget, stage: MappingStage) -> Option { - if !has_mapping_stage(target, stage) { - return None; - } - let items = std::mem::take(&mut self.items); - let (items, outcome) = map_items_at_stage(items, target, stage, None) - .expect("mapping stage applicability was checked before consuming the buffer"); - self.items = items; - self.grouping = GroupingPolicy::ExactSequential; - self.reorder_for_grouping(); - Some(outcome) - } - - fn reorder_for_grouping(&mut self) { - let mut buckets: IndexMap> = IndexMap::new(); - for item in std::mem::take(&mut self.items) { - let title = item.header.group.clone(); - let key_title = match self.grouping { - GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), - GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title, - }; - buckets.entry((item.header.xtream_cluster, key_title)).or_default().push(item); - } - self.items = buckets.into_values().flatten().collect(); - } - - fn into_groups(self) -> Vec { group_items(self.items, self.grouping) } -} - -fn group_items(items: Vec, policy: GroupingPolicy) -> Vec { - let mut groups: IndexMap = IndexMap::new(); - let mut next_group_id = 0; - for item in items { - let title = item.header.group.clone(); - let cluster = item.header.xtream_cluster; - let key_title = match policy { - GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), - GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title.clone(), - }; - groups - .entry((cluster, key_title)) - .or_insert_with(|| { - let id = match policy { - GroupingPolicy::ExactSequential => { - next_group_id += 1; - next_group_id - } - GroupingPolicy::NormalizedCategory | GroupingPolicy::ExactCategory => item.header.category_id, - }; - PlaylistGroup { id, title, channels: Vec::new(), xtream_cluster: cluster } - }) - .channels - .push(item); - } - groups.into_values().collect() -} - -fn execute_pipeline_on_items( - items: Vec, - target: &ConfigTarget, - pipe: &[TransformStage], -) -> (Vec, PipelineOutcome) { - let mut buffer = TransformBuffer::new(items); - let mut outcome = PipelineOutcome::default(); - for stage in pipe { - match stage { - TransformStage::Filter => { - if target.filter.processing.is_some() { - outcome.filter = Some(buffer.apply_filter(target)); - } else { - buffer.normalize_filter_grouping(); - } - } - TransformStage::Rename => outcome.rename = buffer.apply_rename(target), - TransformStage::Map => outcome.mapping = buffer.apply_mapping(target, MappingStage::Processing), - } - } - (buffer.into_groups(), outcome) -} - -fn apply_persist_filter(target: &ConfigTarget, groups: &mut Vec) { - let Some(filter) = target.filter.persist.as_ref() else { - return; - }; - let outcome = retain_filtered_playlist(groups, filter); - debug!("Target '{}' persist filter outcome: {outcome:?}", target.name); -} - -pub(super) fn execute_pipeline_on_groups( - groups: Vec, - target: &ConfigTarget, - pipe: &[TransformStage], -) -> (Vec, PipelineOutcome) { - if pipe.is_empty() { - return (groups, PipelineOutcome::default()); - } - execute_pipeline_on_items(groups.into_iter().flat_map(|group| group.channels).collect(), target, pipe) -} - -fn execute_pipe<'a>( - target: &ConfigTarget, - pipe: &ProcessingPipe, - fpl: &mut FetchedPlaylist<'a>, - duplicates: &mut HashSet, - consume_source: bool, -) -> Result<(FetchedPlaylist<'a>, PipelineOutcome), TuliproxError> { - let source = if consume_source { - if fpl.is_memory() { - MemoryPlaylistSource::new(fpl.source.take_groups()).into_source() - } else { - std::mem::replace(&mut fpl.source, MemoryPlaylistSource::default().into_source()) - } - } else { - fpl.clone_source()? - }; - - let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() }; - // In-memory items are frozen here at the target-processing boundary. Read-only disk sources - // capture the same identity when their persisted M3U/Xtream items are converted to PlaylistItem. - if new_fpl.is_memory() { - for item in new_fpl.items_mut() { - item.header.freeze_input_stream_id(); - } - } - if target.execution_plan.pre_transform_identity_dedup { - new_fpl.deduplicate(duplicates); - } - - let items = new_fpl.source.into_items().collect(); - let (groups, outcome) = execute_pipeline_on_items(items, target, pipe); - new_fpl.source = MemoryPlaylistSource::new(groups).into_source(); - Ok((new_fpl, outcome)) -} - -// This method is needed, because of duplicate group names in different inputs. -// We merge the same group names considering cluster together. -fn flatten_groups(playlistgroups: Vec) -> Vec { - let upper_bound = playlistgroups.len(); - let mut sort_order: Vec = Vec::with_capacity(upper_bound); - let mut idx: usize = 0; - let mut group_map: HashMap = HashMap::with_capacity(upper_bound); - for group in playlistgroups { - let normalized_title: Arc = shared::utils::deunicode_string(&group.title).to_lowercase().intern(); - let key = (group.xtream_cluster, normalized_title); - match group_map.entry(key) { - std::collections::hash_map::Entry::Vacant(v) => { - v.insert(idx); - idx += 1; - sort_order.push(group); - } - std::collections::hash_map::Entry::Occupied(o) => { - if let Some(pl_group) = sort_order.get_mut(*o.get()) { - pl_group.channels.extend(group.channels); - } - } - } - } - sort_order -} - -struct PreparedTarget { - target: ConfigTarget, - playlist: Vec, - epg: Vec, - processing: PipelineStats, -} - -#[allow(clippy::too_many_arguments)] -async fn prepare_playlist_for_target( - ctx: &PlaylistProcessingContext, - playlists: &mut [FetchedPlaylist<'_>], - target: &ConfigTarget, - stats: &mut HashMap, InputStats>, - errors: &mut Vec, - consume_input_source: bool, -) -> Result> { - debug_if_enabled!("Processing order is {}", &target.processing_order); - log_memory_snapshot(format!("target '{}' start", target.name).as_str()); - - let mut duplicates: HashSet = HashSet::new(); - let mut new_epg = vec![]; - let mut new_playlist: Vec = vec![]; - let mut aggregate_outcome = PipelineOutcome::default(); - - debug!("Executing processing pipes"); - let broadcast_step = create_broadcast_callback(&ctx.events); - - let pipe = get_processing_pipe(target); - let mut step = StepMeasure::new(&target.name, broadcast_step); - for provider_fpl in playlists.iter_mut() { - log_memory_snapshot( - format!("target '{}' input '{}' before_pipe", target.name, provider_fpl.input.name).as_str(), - ); - step.broadcast("Executing transformations on '{}' playlist", &target.name); - let (mut processed_fpl, input_outcome) = - execute_pipe(target, &pipe, provider_fpl, &mut duplicates, consume_input_source) - .map_err(|err| vec![err])?; - debug!("Target '{}' input '{}' pipeline outcome: {input_outcome:?}", target.name, provider_fpl.input.name); - aggregate_outcome.merge(input_outcome); - log_memory_snapshot( - format!("target '{}' input '{}' after_pipe", target.name, provider_fpl.input.name).as_str(), - ); - processed_fpl.sort_by_provider_ordinal(); - playlist_resolve(ctx, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; - log_memory_snapshot( - format!("target '{}' input '{}' after_vod_resolve", target.name, provider_fpl.input.name).as_str(), - ); - let clear_invalid_epg_ids = target.options.as_ref().is_some_and(ConfigTargetOptions::clear_invalid_epg_ids); - let input_epg_start = new_epg.len(); - process_playlist_epg(&mut processed_fpl, &mut new_epg, clear_invalid_epg_ids).await; - log_memory_snapshot( - format!("target '{}' input '{}' after_epg_apply", target.name, processed_fpl.input.name).as_str(), - ); - let deduplicate = target.execution_plan.pre_transform_identity_dedup; - if let Some(groups) = map_playlist_at_stage( - &mut processed_fpl.source, - target, - MappingStage::AfterEpg, - deduplicate.then_some(&mut duplicates), - ) { - processed_fpl.source = MemoryPlaylistSource::new(groups).into_source(); - } - if clear_invalid_epg_ids && processed_fpl.epg.is_some() { - clear_invalid_live_epg_ids(&mut processed_fpl, &new_epg[input_epg_start..]); - } - if let Some(stat) = stats.get_mut(&processed_fpl.input.name) { - stat.processed_stats.group_count = processed_fpl.get_group_count(); - stat.processed_stats.channel_count = processed_fpl.get_channel_count(); - } - new_playlist.extend(processed_fpl.source.take_groups()); - log_memory_snapshot( - format!("target '{}' input '{}' after_take_groups", target.name, processed_fpl.input.name).as_str(), - ); - tokio::task::yield_now().await; - } - step.tick("filter rename map + epg"); - log_memory_snapshot(format!("target '{}' after_filter_rename_map_epg", target.name).as_str()); - step.stop("Preparing playlist"); - Ok(PreparedTarget { - target: target.clone(), - playlist: new_playlist, - epg: new_epg, - processing: aggregate_outcome.to_stats(), - }) -} - -/// Spill each `Epg` source to a temp `BPlusTree` and merge them. Extracted -/// from `finalize_prepared_target` so it can be unit-tested without -/// constructing a full `PlaylistProcessingContext`. -/// -/// Returns `Ok(None)` if `sources` is empty (no EPG to merge), matching -/// the contract of `flatten_tvguide`. The temp directory lives inside -/// this function call — all temp files are removed by the -/// `DiskEpgSource` drop guards before this function returns. -fn spill_epg_to_disk(sources: Vec) -> Result, TuliproxError> { - let dir = - tempfile::tempdir().map_err(|e| TuliproxError::RepositoryXtream(format!("tempdir for EPG spill: {e}")))?; - let mut disk_sources = Vec::with_capacity(sources.len()); - for (source_order, guide) in sources.into_iter().enumerate() { - let mut acc = EpgMergeAccumulator::new(); - acc.set_attributes_if_preferred(guide.priority, source_order, guide.attributes); - for channel in guide.children { - acc.add_channel_with_programmes( - guide.priority, - source_order, - guide.logo_override, - std::sync::Arc::unwrap_or_clone(channel), - ); - } - let path = dir.path().join(format!("epg-src-{source_order}.db")); - let source_order_u32 = u32::try_from(source_order).unwrap_or(0); - let source = acc - .finish_into_disk(path, guide.priority, source_order_u32) - .map_err(|e| TuliproxError::RepositoryXtream(format!("EPG spill to disk failed: {e}")))?; - disk_sources.push(source); - } - if disk_sources.is_empty() { - Ok(None) - } else { - merge_epg_trees(disk_sources) - .map_err(|e| TuliproxError::RepositoryXtream(format!("EPG disk merge failed: {e}"))) - .map(|opt| opt.map(|(epg, _)| epg)) - } -} - -async fn finalize_prepared_target( - ctx: Arc>, - prepared: PreparedTarget, -) -> (Result<(), Vec>, Vec) { - let target = &prepared.target; - let mut new_playlist = prepared.playlist; - let mut new_epg = prepared.epg; - let mut errors = Vec::new(); - let broadcast_step = create_broadcast_callback(&ctx.events); - let mut step = StepMeasure::new(&target.name, broadcast_step); - if target.favourites.is_some() { - step.broadcast("Processing favourites for '{}' playlist", &target.name); - process_favourites(&mut new_playlist, target.favourites.as_deref()); - log_memory_snapshot(format!("target '{}' after_favourites", target.name).as_str()); - } - - if new_playlist.is_empty() { - step.stop(""); - info!("Playlist is empty: {}", target.name); - (Ok(()), errors) - } else { - // Process Trakt categories - if trakt_playlist(&ctx.client, target, &mut errors, &mut new_playlist).await { - step.tick("trakt categories"); - log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str()); - } - - let mut flat_new_playlist = flatten_groups(new_playlist); - step.tick("playlist merge"); - log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str()); - - for stage in FINALIZATION_ORDER.into_iter().skip(1) { - match stage { - FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"), - FinalizationStage::Deduplicate => { - if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() { - let removed = - crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist); - if removed > 0 { - info!("Deduplicated {removed} channels for target {}", target.name); - } - step.tick("playlist dedup"); - log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str()); - } - } - FinalizationStage::Sort => { - if sort_playlist(target, &mut flat_new_playlist) { - step.tick("playlist sort"); - log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str()); - } - } - FinalizationStage::AssignChannelNumbers => { - assign_channel_no_playlist(&mut flat_new_playlist); - step.tick("assigning channel numbers"); - log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str()); - } - FinalizationStage::AssignCounters => { - map_playlist_counter(target, &mut flat_new_playlist); - step.tick("assigning channel counter"); - log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str()); - } - } - } - - apply_persist_filter(target, &mut flat_new_playlist); - retain_epg_referenced_by_groups(&flat_new_playlist, &mut new_epg); - - if process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await { - step.tick("group watches"); - log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str()); - } - let merged_epg = if ctx.config.config.load().disk_based_processing { - // Per-source drain to disk, then multi-way merge. Errors are pushed - // to `errors` rather than `?` because the function returns - // `(Result, Vec)`, not `Result` directly. We must - // surface tempdir / write / merge failures — the user opted in to - // disk spilling, and silently falling back to the in-memory path - // can OOM on large feeds. When the spill itself fails we skip the - // persist step entirely: continuing with `merged_epg = None` would - // overwrite the existing on-disk EPG with nothing and discard the - // previously persisted artifact on a transient error. - match spill_epg_to_disk(new_epg) { - Ok(epg) => epg, - Err(err) => { - errors.push(err); - step.stop("EPG spill failed; skipping persist to preserve existing EPG"); - log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str()); - return (Ok(()), errors); - } - } - } else { - flatten_tvguide(new_epg) - }; - let result = persist_playlist( - &ctx.config, - &mut flat_new_playlist, - merged_epg.as_ref(), - target, - ctx.playlist_state.as_ref(), - ) - .await; - step.stop("Persisting playlists"); - log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str()); - (result, errors) - } -} - -async fn playlist_resolve( - ctx: &PlaylistProcessingContext, - target: &ConfigTarget, - errors: &mut Vec, - pipe: &ProcessingPipe, - provider_fpl: &mut FetchedPlaylist<'_>, - processed_fpl: &mut FetchedPlaylist<'_>, -) { - playlist_resolve_series(ctx, target, errors, pipe, provider_fpl, processed_fpl).await; - playlist_resolve_vod(ctx, target, errors, provider_fpl, processed_fpl).await; - playlist_probe(ctx, target, processed_fpl).await; -} - -fn is_probe_supported_item_type(item_type: PlaylistItemType) -> bool { - matches!( - item_type, - PlaylistItemType::Live // we skip other live streams because hls and dash have multiple resolutions - | PlaylistItemType::Video - | PlaylistItemType::LocalVideo - | PlaylistItemType::Series - | PlaylistItemType::LocalSeries - ) -} - -fn has_probe_details(item: &PlaylistItem) -> bool { - match item.header.additional_properties.as_ref() { - Some(StreamProperties::Video(v)) => v.details.as_ref().is_some_and(|d| d.video.is_some() && d.audio.is_some()), - Some(StreamProperties::Live(l)) => l.video.is_some() && l.audio.is_some() && l.bitrate > 0, - Some(StreamProperties::Episode(e)) => e.video.is_some() && e.audio.is_some(), - Some(StreamProperties::Series(_)) | None => false, - } -} - -fn get_live_probe_interval_settings( - target: &ConfigTarget, - input_type: InputType, - input_options: Option<&ConfigInputOptions>, -) -> Option<(u16, u64)> { - if !(input_type.is_xtream() || input_type.is_m3u() || input_type.is_stalker()) { - return None; - } - target.get_xtream_output().map(|_| { - let (probe_delay, input_probe_live_interval_hours) = input_options - .map_or((default_probe_delay_secs(), default_probe_live_interval()), |options| { - (options.probe_delay, options.probe_live_interval_hours) - }); - (probe_delay, u64::from(input_probe_live_interval_hours) * 3600) - }) -} - -fn needs_live_probe(item: &PlaylistItem, cutoff_ts: i64) -> bool { - match item.header.additional_properties.as_ref() { - Some(StreamProperties::Live(props)) => { - props.bitrate == 0 || props.last_probed_timestamp.is_none_or(|last_ts| last_ts < cutoff_ts) - } - _ => true, - } -} - -fn provider_id_from_item(item: &PlaylistItem) -> Option { - if let Ok(id) = item.header.id.parse::() { - if id == 0 { - return None; - } - return Some(ProviderIdType::Id(id)); - } - - let raw = item.header.id.trim(); - if raw.is_empty() { - None - } else { - Some(ProviderIdType::from(raw)) - } -} - -#[allow(clippy::too_many_lines)] -async fn playlist_probe( - ctx: &PlaylistProcessingContext, - target: &ConfigTarget, - fpl: &mut FetchedPlaylist<'_>, -) { - let Some(mgr) = ctx.metadata_manager.as_ref() else { - return; - }; - let Some(opts) = fpl.input.options.as_ref() else { - return; - }; - let probe_live_enabled = opts.has_flag(ConfigInputFlags::ProbeLive); - let probe_vod_enabled = opts.has_flag(ConfigInputFlags::ProbeVod); - let probe_series_enabled = opts.has_flag(ConfigInputFlags::ProbeSeries); - - if !(probe_live_enabled || probe_vod_enabled || probe_series_enabled) { - return; - } - if !ctx.config.is_ffprobe_enabled().await { - return; - } - - let input_name = fpl.input.name.clone(); - // The first `should_skip_enqueue` for an input needs its persisted enqueue - // state on disk; inputs where no item reaches that check must not pay for - // the load, so it happens on first use rather than here. - let mut enqueue_state_prepared = false; - let effective_input_type = fpl.input.get_download_input_type(); - let xtream_probe_handled = effective_input_type.is_xtream() && target.get_xtream_output().is_some(); - let live_probe_settings = if probe_live_enabled { - get_live_probe_interval_settings(target, effective_input_type, Some(opts)).map(|(delay, interval_secs)| { - let interval_signed = i64::try_from(interval_secs).unwrap_or(i64::MAX); - let cutoff_ts = chrono::Utc::now().timestamp().saturating_sub(interval_signed); - (delay, interval_secs, cutoff_ts) - }) - } else { - None - }; - - let mut queued_probe_keys: HashSet<(Arc, String)> = HashSet::new(); - let mut queued_live_keys: HashSet = HashSet::new(); - let mut queued_live_count = 0usize; - let mut queued_stream_count = 0usize; - - let probe_filter = fpl.input.options.as_ref().and_then(|o| o.probe_filter.as_ref()); - - for item in fpl.items() { - if !is_probe_supported_item_type(item.header.item_type) { - continue; - } - match item.header.item_type { - PlaylistItemType::Live => { - if !probe_live_enabled { - continue; - } - } - PlaylistItemType::Video | PlaylistItemType::LocalVideo => { - if !probe_vod_enabled { - continue; - } - } - PlaylistItemType::Series | PlaylistItemType::LocalSeries => { - if !probe_series_enabled { - continue; - } - } - _ => continue, - } - - // If input has a probe filter and this item doesn't match, skip probing - if let Some(p_filter) = probe_filter { - let provider = ValueProvider { pli: &item, match_as_ascii: false }; - if !p_filter.filter(&provider) { - continue; - } - } - - match item.header.item_type { - PlaylistItemType::Live => { - if let Some((probe_delay, interval_secs, cutoff_ts)) = live_probe_settings { - if needs_live_probe(&item, cutoff_ts) { - if let Some(provider_id) = provider_id_from_item(&item) { - if queued_live_keys.insert(provider_id.clone()) { - let task = UpdateTask::ProbeLive { - id: provider_id.clone(), - reason: ResolveReason::Probe.into(), - delay: probe_delay, - interval: interval_secs, - }; - if !enqueue_state_prepared { - mgr.prepare_enqueue_state(input_name.clone()).await; - enqueue_state_prepared = true; - } - if mgr.should_skip_enqueue(&input_name, &task) { - continue; - } - if log_enabled!(Level::Debug) { - let last_probed = match item.header.additional_properties.as_ref() { - Some(StreamProperties::Live(props)) => props.last_probed_timestamp, - _ => None, - }; - debug!( - "[Task] Creating ProbeLive task for input {}: id={}, last_probed_ts={:?}, cutoff_ts={}, interval={}s, title=\"{}\"", - input_name, - provider_id, - last_probed, - cutoff_ts, - interval_secs, - item.header.title - ); - } - Arc::clone(mgr).queue_task_background(input_name.clone(), task); - queued_live_count += 1; - } - } - } - continue; - } - // If live probes are enabled but no live-specific settings are available, fall through to the - // generic probe path to keep behaviour consistent with non-xtream outputs. - } - PlaylistItemType::Video | PlaylistItemType::LocalVideo => { - // Xtream outputs handle VOD probe as part of the resolve pipeline (after resolve). - if xtream_probe_handled { - continue; - } - } - PlaylistItemType::Series | PlaylistItemType::LocalSeries => { - // Xtream outputs handle Series probe as part of the resolve pipeline (after resolve). - if xtream_probe_handled { - continue; - } - } - _ => continue, - } - - if has_probe_details(&item) { - continue; - } - - // For M3U, ID is a provider id; for Library, ID is UUID. - let unique_id = if effective_input_type == InputType::Library { - item.header.uuid.to_valid_uuid() - } else { - item.header.id.to_string() - }; - let probe_scope = - if item.header.input_name.is_empty() { input_name.clone() } else { item.header.input_name.clone() }; - - if !queued_probe_keys.insert((probe_scope.clone(), unique_id.clone())) { - continue; - } - - let task = UpdateTask::ProbeStream { - probe_scope: probe_scope.clone(), - unique_id: unique_id.clone(), - url: item.header.url.to_string(), - item_type: item.header.item_type, - reason: ResolveReason::MissingDetails.into(), - delay: opts.probe_delay, - }; - if !enqueue_state_prepared { - mgr.prepare_enqueue_state(input_name.clone()).await; - enqueue_state_prepared = true; - } - if mgr.should_skip_enqueue(&input_name, &task) { - continue; - } - debug!( - "[Task] Creating ProbeStream task for input {}: scope={}, unique_id={}, item_type={:?}, title=\"{}\"", - input_name, probe_scope, unique_id, item.header.item_type, item.header.title - ); - Arc::clone(mgr).queue_task_background(input_name.clone(), task); - queued_stream_count += 1; - } - - if queued_live_count > 0 || queued_stream_count > 0 { - info!( - "Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})" - ); - } -} - -pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Option<&[ConfigFavourites]>) { - if let Some(favourites) = favourites_cfg { - let mut fav_groups: IndexMap> = IndexMap::new(); - for pg in playlist.iter() { - for pli in &pg.channels { - // series episodes can't be included in favourites - if pli.header.item_type == PlaylistItemType::Series - || pli.header.item_type == PlaylistItemType::LocalSeries - { - continue; - } - for fav in favourites { - if pli.header.xtream_cluster == fav.cluster && is_valid(pli, &fav.filter, fav.match_as_ascii) { - let mut channel = pli.clone(); - channel.header.group.clone_from(&fav.group); - // Update UUID to be an alias of the original - channel.header.uuid = create_alias_uuid(&pli.header.uuid, &fav.group); - fav_groups.entry((fav.cluster, fav.group.clone())).or_default().push(channel); - } - } - } - } - - for (fav_group, channels) in fav_groups { - if !channels.is_empty() { - let (xtream_cluster, group_name) = fav_group; - playlist.push(PlaylistGroup { id: 0, title: group_name, channels, xtream_cluster }); - } - } - } -} - -async fn trakt_playlist( - client: &reqwest::Client, - target: &ConfigTarget, - errors: &mut Vec, - playlist: &mut Vec, -) -> bool { - match process_trakt_categories_for_target(client, playlist, target).await { - Ok(Some(trakt_categories)) => { - if !trakt_categories.is_empty() { - info!("Adding {} Trakt categories to playlist", trakt_categories.len()); - playlist.extend(trakt_categories); - } - } - Ok(None) => { - return false; - } - Err(trakt_errors) => { - warn!("Trakt processing failed with {} errors", trakt_errors.len()); - errors.extend(trakt_errors); - } - } - true -} - -async fn process_watch( - app_config: &Arc, - events: &E, - target: &ConfigTarget, - new_playlist: &[PlaylistGroup], -) -> bool { - let Some(watches) = &target.watch else { - return false; - }; - - // Configured, but every pattern failed to compile. Silently doing - // nothing here is what made a typo in `watch` indistinguishable from a - // playlist that never changes. - if watches.is_empty() { - error!("target '{}' configured watch patterns but none of them compiled", target.name); - events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( - target.name.clone(), - WatchDisabledReason::InvalidPatterns, - ))); - return false; - } - - if default_as_default().eq_ignore_ascii_case(&target.name) { - error!("can't watch a target with no unique name"); - events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( - target.name.clone(), - WatchDisabledReason::UnnamedTarget, - ))); - return false; - } - - // Before the per-group fan-out: this is about which groups exist, not - // what is inside the ones the patterns name, so it must see every group - // rather than only the watched ones. - process_target_groups_watch(app_config, events, &target.name, new_playlist).await; - - let mut matched = vec![false; watches.len()]; - let mut watched_groups = Vec::new(); - for group in new_playlist { - let mut any = false; - for (index, pattern) in watches.iter().enumerate() { - if pattern.is_match(&group.title) { - matched[index] = true; - any = true; - } - } - if any { - watched_groups.push(group); - } - } - - // A pattern that matches nothing looks exactly like a group that has not - // changed. `EventKindMask::from_wire_names` already reports unmatched - // subscription names for the same reason: a typo must surface. - let unmatched: Vec = watches - .iter() - .enumerate() - .filter(|(index, _)| !matched[*index]) - .map(|(_, pattern)| pattern.as_str().to_string()) - .collect(); - if !unmatched.is_empty() { - warn!("target '{}' has {} watch pattern(s) matching no group", target.name, unmatched.len()); - events.emit(EventMessage::PlaylistWatchUnmatched(WatchUnmatched::new( - target.name.clone(), - unmatched, - new_playlist.len(), - ))); - } - - futures::stream::iter( - watched_groups.into_iter().map(|pl| process_group_watch(app_config, events, &target.name, pl)), - ) - .for_each_concurrent(16, |f| f) - .await; - - true -} - -/// Work the composition root runs once the playlist lock is held, before the -/// update proper starts. -/// -/// This was an `Option>` used for exactly one call. Passing the -/// call instead of the state keeps `processing` from naming the server state. -/// -/// It was then an -/// `Arc Pin + Send>> + Send + Sync>`: -/// two layers of erasure and a heap allocation for a future that is awaited -/// exactly once per update, and every call site had to spell out both -/// coercions. As a trait it is one type parameter, monomorphised, with the -/// future returned by value. -pub trait UpdateBootstrap: Send + Sync + 'static { - fn run(&self) -> impl Future + Send; -} - -impl UpdateBootstrap for F -where - F: Fn() -> Fut + Send + Sync + 'static, - Fut: Future + Send, -{ - fn run(&self) -> impl Future + Send { self() } -} - -/// The bootstrap type parameter of a run that has no bootstrap. -/// -/// A function pointer rather than a unit struct: it satisfies the blanket -/// `Fn` impl above, so no second impl - and no coherence problem - is needed. -/// A value of this type is never constructed; the field is always `None`. -pub type NoBootstrap = fn() -> std::future::Ready<()>; - -/// Everything one playlist update run needs. -/// -/// `exec_processing` took twelve positional arguments, seven of them -/// `Option<_>`, so a call site was a wall of `None`s and `Some(..)`s in which -/// the reader had to count commas to work out which knob was being set - and -/// the compiler could not catch two same-typed arguments swapped. -/// -/// Four of the twelve are always present, so they are constructor arguments. -/// The rest are optional in fact as well as in type, and a call site names the -/// ones it actually sets. -pub struct ProcessingRun< - E: EventSink + Clone + 'static, - B: UpdateBootstrap = NoBootstrap, - M: MetadataUpdateSink = NoopMetadataSink, -> { - client: reqwest::Client, - app_config: Arc, - targets: Arc, - events: E, - bootstrap: Option, - playlist_state: Option>, - update_guard: Option, - disabled_headers: Option, - provider_manager: Option>, - metadata_manager: Option>, - pre_processed_inputs: Option>>, - acquired_permit: Option, -} - -impl ProcessingRun { - pub fn new(client: reqwest::Client, app_config: Arc, targets: Arc, events: E) -> Self { - Self { - client, - app_config, - targets, - events, - bootstrap: None, - playlist_state: None, - update_guard: None, - disabled_headers: None, - provider_manager: None, - metadata_manager: None, - pre_processed_inputs: None, - acquired_permit: None, - } - } -} - -impl ProcessingRun { - /// Work the composition root runs once the lock is held, before the update - /// proper starts. - /// - /// Changes the run's bootstrap type, so it rebuilds rather than mutates. - #[must_use] - pub fn with_bootstrap(self, bootstrap: B2) -> ProcessingRun { - ProcessingRun { - client: self.client, - app_config: self.app_config, - targets: self.targets, - events: self.events, - bootstrap: Some(bootstrap), - playlist_state: self.playlist_state, - update_guard: self.update_guard, - disabled_headers: self.disabled_headers, - provider_manager: self.provider_manager, - metadata_manager: self.metadata_manager, - pre_processed_inputs: self.pre_processed_inputs, - acquired_permit: self.acquired_permit, - } - } - - #[must_use] - pub fn with_playlist_state(mut self, state: impl Into>>) -> Self { - self.playlist_state = state.into(); - self - } - - /// The lock this run acquires. Ignored when an already-acquired permit is - /// supplied via [`Self::with_acquired_permit`]. - #[must_use] - pub fn with_update_guard(mut self, guard: impl Into>) -> Self { - self.update_guard = guard.into(); - self - } - - #[must_use] - pub fn with_disabled_headers(mut self, headers: impl Into>) -> Self { - self.disabled_headers = headers.into(); - self - } - - #[must_use] - pub fn with_provider_manager(mut self, manager: impl Into>>) -> Self { - self.provider_manager = manager.into(); - self - } - - /// The background metadata worker. - /// - /// Changes the run's sink type, so it rebuilds rather than mutates. - #[must_use] - pub fn with_metadata_manager(self, manager: Arc) -> ProcessingRun { - ProcessingRun { - client: self.client, - app_config: self.app_config, - targets: self.targets, - events: self.events, - bootstrap: self.bootstrap, - playlist_state: self.playlist_state, - update_guard: self.update_guard, - disabled_headers: self.disabled_headers, - provider_manager: self.provider_manager, - metadata_manager: Some(manager), - pre_processed_inputs: self.pre_processed_inputs, - acquired_permit: self.acquired_permit, - } - } - - // Always built with the default hasher here; generalising would buy nothing. - #[allow(clippy::implicit_hasher)] - #[must_use] - pub fn with_pre_processed_inputs(mut self, inputs: impl Into>>>) -> Self { - self.pre_processed_inputs = inputs.into(); - self - } - - /// A playlist lock the caller already holds. Takes precedence over - /// [`Self::with_update_guard`], which would otherwise acquire a second one. - #[must_use] - pub fn with_acquired_permit(mut self, permit: impl Into>) -> Self { - self.acquired_permit = permit.into(); - self - } -} - -#[allow(clippy::too_many_lines)] -pub async fn exec_processing( - run: ProcessingRun, -) { - let ProcessingRun { - client, - app_config, - targets, - events, - bootstrap, - playlist_state, - update_guard, - disabled_headers, - provider_manager, - metadata_manager, - pre_processed_inputs, - acquired_permit, - } = run; - - let max_update_duration = Duration::from_secs(PLAYLIST_UPDATE_MAX_DURATION_SECS); - let playlist_guard = if let Some(permit) = acquired_permit { - Some(permit) - } else if let Some(guard) = &update_guard { - if let Some(permit) = guard.acquire_playlist_lock().await { - Some(permit) - } else { - warn!("Playlist update lock is closed; update skipped."); - events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( - shared::model::PlaylistUpdateState::Failure, - ))); - return; - } - } else { - None - }; - - if playlist_guard.is_some() { - if let Some(bootstrap) = bootstrap.as_ref() { - if tokio::time::timeout(max_update_duration, bootstrap.run()).await.is_err() { - error!( - "Playlist update bootstrap timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", - ); - events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( - shared::model::PlaylistUpdateState::Failure, - ))); - return; - } - } - } - - // Pause background metadata/probe tasks for the full update lifecycle. - let _background_pause_guard = if let Some(manager) = metadata_manager.as_ref() { - Some(manager.acquire_update_pause_guard().await) - } else { - None - }; - - info!("🌷 Update process started."); - - log_memory_snapshot("exec_processing start"); - - // Initialize Context - let ctx = PlaylistProcessingContext { - client, - config: app_config.clone(), - user_targets: targets.clone(), - events: events.clone(), - playlist_state: playlist_state.clone(), - processed_inputs: Arc::new(Mutex::new(HashSet::new())), - input_locks: Arc::new(Mutex::new(HashMap::new())), - disabled_headers, - provider_manager, - metadata_manager, - pre_processed_inputs: pre_processed_inputs.map(Arc::new), - stalker_refresh_mode: if app_config.config.load().process_parallel { - StalkerRefreshMode::Parallel - } else if update_guard.is_some() { - StalkerRefreshMode::ServerSlice - } else { - StalkerRefreshMode::Complete - }, - partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }; - - let start_time = Instant::now(); - let process_result = - tokio::time::timeout(max_update_duration, std::panic::AssertUnwindSafe(process_sources(&ctx)).catch_unwind()) - .await; - let (stats, errors) = match process_result { - Ok(Ok((stats, errors))) => (stats, errors), - Ok(Err(_)) => { - error!("Playlist processing panicked"); - events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( - shared::model::PlaylistUpdateState::Failure, - ))); - return; - } - Err(_) => { - error!( - "Playlist processing timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", - ); - events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( - shared::model::PlaylistUpdateState::Failure, - ))); - return; - } - }; - log_memory_snapshot("exec_processing after_process_sources"); - - // Keep the update lock only for the critical processing section. - drop(playlist_guard); - debug!("Released playlist update lock; dispatching notifications and events"); - - // log errors - for err in &errors { - error!("{}", err.message()); - } - - if !stats.is_empty() { - if let Ok(stats_msg) = serde_json::to_string(&stats) { - info!("stats: {stats_msg}"); - } - } - - // One event for the whole run, carrying both the outcome and what it - // did. These used to be two independent messages - the statistics went - // straight to the notification layer, the outcome went to the bus - and - // because both resolve to `playlist.update.completed`, a successful - // refresh notified twice. Subscribers now get one event with everything, - // and the bridge renders the single message from it. - let error = get_errors_notify_message!(errors, 255); - let outcome = if error.is_some() { - shared::model::PlaylistUpdateState::Failure - } else if ctx.partial_refresh.load(std::sync::atomic::Ordering::Acquire) { - shared::model::PlaylistUpdateState::Partial - } else { - shared::model::PlaylistUpdateState::Success - }; - events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary { state: outcome, stats, error })); - - let elapsed = start_time.elapsed().as_secs(); - let update_finished_message = format!("🌷 Update process finished! Took {elapsed} secs."); - - events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: "Playlist Update".to_string(), - message: update_finished_message.clone(), - })); - log_memory_snapshot("exec_processing before_interner_gc"); - debug!("StringInterner GC removed {} strings", interner_gc()); - log_memory_snapshot("exec_processing after_interner_gc"); - //trim_allocator_after_update(); - - info!("{update_finished_message}"); -} - -#[cfg(test)] -mod tests { - use super::*; - use shared::{ - foundation::{get_filter, MapperScript, ValueProvider}, - model::{ - ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor, - ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, - PlaylistItemType, XtreamCluster, XtreamPlaylistItem, - }, - utils::Internable, - }; - use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config}; - - fn serialize_without_trailing_fields(value: &T, trailing_fields: &[u8]) -> Vec { - let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize"); - for expected in trailing_fields { - assert_eq!(encoded.pop(), Some(*expected), "unexpected trailing MessagePack field"); - } - let removed = trailing_fields.len(); - match encoded[0] { - marker @ 0x92..=0x9f => { - let len = usize::from(marker - 0x90); - assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); - encoded[0] = 0x90 + u8::try_from(len - removed).unwrap_or_default(); - } - 0xdc => { - let len = u16::from_be_bytes([encoded[1], encoded[2]]); - let removed = u16::try_from(removed).unwrap_or(u16::MAX); - assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); - encoded[1..3].copy_from_slice(&(len - removed).to_be_bytes()); - } - 0xdd => { - let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]); - let removed = u32::try_from(removed).unwrap_or(u32::MAX); - assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); - encoded[1..5].copy_from_slice(&(len - removed).to_be_bytes()); - } - marker => panic!("unexpected MessagePack sequence marker {marker:#x}"), - } - encoded - } - - fn item_with_props(props: StreamProperties) -> PlaylistItem { - let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() }; - PlaylistItem { header } - } - - fn live_item_with_probe_timestamp_and_bitrate(last_probed_timestamp: i64, bitrate: u32) -> PlaylistItem { - item_with_props(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: Some("{\"codec_name\":\"aac\"}".intern()), - bitrate, - last_probed_timestamp: Some(last_probed_timestamp), - ..Default::default() - }))) - } - - #[test] - fn rename_preserves_input_stream_id_captured_at_target_boundary() { - let mut item = PlaylistItem { - header: PlaylistItemHeader { - id: "origin-alpha".intern(), - url: "http://provider.example/channel.m3u8".intern(), - ..Default::default() - }, - }; - item.header.freeze_input_stream_id(); - let rename = ConfigRename::from(&ConfigRenameDto { - field: ItemField::Url, - pattern: "provider".to_string(), - new_name: "target".to_string(), - t_pattern: None, - }); - - exec_rename(&mut item, Some(&vec![rename])); - - assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8"); - assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha"); - } - - #[test] - fn mapper_changes_id_without_changing_frozen_input_stream_id() { - let mut item = PlaylistItem { - header: PlaylistItemHeader { id: "origin-alpha".intern(), name: "Channel".intern(), ..Default::default() }, - }; - item.header.freeze_input_stream_id(); - let mapping = CompiledMapping { - rules: vec![CompiledMappingRule { - name: None, - filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), - program: MappingProgram::Script( - MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), - ), - }], - ..Default::default() - }; - - let outcome = map_channel(item, &mapping); - - assert_eq!(outcome.matched_rules, 1); - assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); - assert_eq!(outcome.channel.header.input_stream_id.as_ref(), "origin-alpha"); - } - - #[test] - fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() { - let mut source = PlaylistItem { - header: PlaylistItemHeader { - id: "80510".intern(), - url: "http://provider.example/live/user/pass/80510.ts".intern(), - input_name: "input".intern(), - item_type: PlaylistItemType::Live, - xtream_cluster: XtreamCluster::Live, - ..Default::default() - }, - }; - source.header.freeze_input_stream_id(); - let mut legacy_xtream = XtreamPlaylistItem::from(&source); - legacy_xtream.provider_id = 0; - legacy_xtream.input_stream_id = "".intern(); - legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern(); - let mut legacy_item = PlaylistItem::from(&legacy_xtream); - legacy_item.header.freeze_input_stream_id(); - let mapping = CompiledMapping { - rules: vec![CompiledMappingRule { - name: None, - filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), - program: MappingProgram::Script( - MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), - ), - }], - ..Default::default() - }; - - let outcome = map_channel(legacy_item, &mapping); - let materialized_m3u = M3uPlaylistItem::from(&outcome.channel); - let materialized_xtream = XtreamPlaylistItem::from(&outcome.channel); - - assert_eq!(outcome.matched_rules, 1); - assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); - assert_eq!(outcome.channel.get_input_stream_id(), None); - assert!(materialized_m3u.provider_id.is_empty()); - assert_eq!(materialized_m3u.get_input_stream_id(), None); - assert_eq!(materialized_xtream.provider_id, 0); - assert_eq!(materialized_xtream.get_input_stream_id(), None); - } - - #[test] - fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() { - let input = ConfigInput::default(); - let item = PlaylistItem { header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() } }; - let source = MemoryPlaylistSource::new(vec![PlaylistGroup { - id: 1, - title: "Group".intern(), - channels: vec![item], - xtream_cluster: XtreamCluster::Live, - }]) - .into_source(); - let mut fetched = FetchedPlaylist { input: &input, source, epg: None }; - let mut duplicates = HashSet::new(); - let target = ConfigTarget::from(&ConfigTargetDto::default()); - - let (mut processed, _outcome) = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false) - .expect("target processing should succeed"); - let mut groups = processed.source.take_groups(); - - assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha"); - assert!(groups[0].channels[0].header.set_field("id", "late-target-id")); - assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha"); - } - - #[test] - fn legacy_messagepack_playlist_items_default_missing_input_stream_id() { - let mut source = PlaylistItem { - header: PlaylistItemHeader { - id: "origin-alpha".intern(), - url: "http://provider.example/live/user/pass/80510.ts".intern(), - input_name: "input".intern(), - item_type: PlaylistItemType::Live, - xtream_cluster: XtreamCluster::Live, - ..Default::default() - }, - }; - - let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]); - let decoded_header: PlaylistItemHeader = - rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize"); - assert!(decoded_header.input_stream_id.is_empty()); - assert_eq!(decoded_header.get_input_stream_id(), None); - assert_eq!(decoded_header.upstream_user_agent, None); - let mut decoded_header = decoded_header; - decoded_header.freeze_input_stream_id(); - assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha")); - - source.header.freeze_input_stream_id(); - let mut m3u_item = M3uPlaylistItem::from(&source); - m3u_item.input_stream_id = "".intern(); - let m3u_bytes = serialize_without_trailing_fields(&m3u_item, &[0xc0, 0xa0]); - let decoded_m3u: M3uPlaylistItem = - rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize"); - assert!(decoded_m3u.input_stream_id.is_empty()); - assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha")); - assert_eq!(decoded_m3u.upstream_user_agent, None); - - let mut xtream_item = XtreamPlaylistItem::from(&source); - xtream_item.input_stream_id = "".intern(); - let xtream_bytes = serialize_without_trailing_fields(&xtream_item, &[0xc0, 0xa0]); - let decoded_xtream: XtreamPlaylistItem = - rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize"); - assert!(decoded_xtream.input_stream_id.is_empty()); - assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510")); - assert_eq!(decoded_xtream.upstream_user_agent, None); - } - - #[test] - fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() { - let source = PlaylistItem { - header: PlaylistItemHeader { - id: "80510".intern(), - input_stream_id: "origin-alpha".intern(), - ..Default::default() - }, - }; - - let header: PlaylistItemHeader = - rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0])) - .expect("previous header should deserialize"); - let m3u: M3uPlaylistItem = - rmp_serde::from_slice(&serialize_without_trailing_fields(&M3uPlaylistItem::from(&source), &[0xc0])) - .expect("previous M3U item should deserialize"); - let xtream: XtreamPlaylistItem = - rmp_serde::from_slice(&serialize_without_trailing_fields(&XtreamPlaylistItem::from(&source), &[0xc0])) - .expect("previous Xtream item should deserialize"); - - assert_eq!(header.input_stream_id.as_ref(), "origin-alpha"); - assert_eq!(m3u.input_stream_id.as_ref(), "origin-alpha"); - assert_eq!(xtream.input_stream_id.as_ref(), "origin-alpha"); - assert_eq!(header.upstream_user_agent, None); - assert_eq!(m3u.upstream_user_agent, None); - assert_eq!(xtream.upstream_user_agent, None); - } - - #[test] - fn messagepack_playlist_items_preserve_upstream_user_agent() -> Result<(), Box> { - let source = PlaylistItem { - header: PlaylistItemHeader { upstream_user_agent: Some("Provider-UA".intern()), ..Default::default() }, - }; - - let header: PlaylistItemHeader = rmp_serde::from_slice(&rmp_serde::to_vec(&source.header)?)?; - let m3u: M3uPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&M3uPlaylistItem::from(&source))?)?; - let xtream: XtreamPlaylistItem = - rmp_serde::from_slice(&rmp_serde::to_vec(&XtreamPlaylistItem::from(&source))?)?; - - assert_eq!(header.upstream_user_agent.as_deref(), Some("Provider-UA")); - assert_eq!(m3u.upstream_user_agent.as_deref(), Some("Provider-UA")); - assert_eq!(xtream.upstream_user_agent.as_deref(), Some("Provider-UA")); - Ok(()) - } - - #[test] - fn has_probe_details_requires_video_and_audio_for_video() { - let video = shared::model::VideoStreamProperties { - details: Some(shared::model::VideoStreamDetailProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: None, - ..Default::default() - }), - ..Default::default() - }; - let item_missing_audio = item_with_props(StreamProperties::Video(Box::new(video))); - assert!(!has_probe_details(&item_missing_audio)); - - let video_complete = shared::model::VideoStreamProperties { - details: Some(shared::model::VideoStreamDetailProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: Some("{\"codec_name\":\"aac\"}".intern()), - ..Default::default() - }), - ..Default::default() - }; - let item_complete = item_with_props(StreamProperties::Video(Box::new(video_complete))); - assert!(has_probe_details(&item_complete)); - } - - #[test] - fn has_probe_details_requires_video_audio_and_bitrate_for_live() { - let live_missing_audio = shared::model::LiveStreamProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: None, - ..Default::default() - }; - let item_missing_audio = item_with_props(StreamProperties::Live(Box::new(live_missing_audio))); - assert!(!has_probe_details(&item_missing_audio)); - - let live_missing_bitrate = shared::model::LiveStreamProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: Some("{\"codec_name\":\"aac\"}".intern()), - ..Default::default() - }; - let item_missing_bitrate = item_with_props(StreamProperties::Live(Box::new(live_missing_bitrate))); - assert!(!has_probe_details(&item_missing_bitrate)); - - let live_complete = shared::model::LiveStreamProperties { - video: Some("{\"codec_name\":\"h264\"}".intern()), - audio: Some("{\"codec_name\":\"aac\"}".intern()), - bitrate: 2_500_000, - ..Default::default() - }; - let item_complete = item_with_props(StreamProperties::Live(Box::new(live_complete))); - assert!(has_probe_details(&item_complete)); - } - - #[test] - fn needs_live_probe_when_fresh_probe_has_no_bitrate() { - let item = live_item_with_probe_timestamp_and_bitrate(101, 0); - - assert!(needs_live_probe(&item, 100)); - } - - #[test] - fn does_not_need_live_probe_when_fresh_probe_has_positive_bitrate() { - let item = live_item_with_probe_timestamp_and_bitrate(101, 2_500_000); - - assert!(!needs_live_probe(&item, 100)); - } - - #[test] - fn needs_live_probe_when_positive_bitrate_probe_is_older_than_cutoff() { - let item = live_item_with_probe_timestamp_and_bitrate(99, 2_500_000); - - assert!(needs_live_probe(&item, 100)); - } - - #[test] - fn has_probe_details_is_false_for_series() { - let series = shared::model::SeriesStreamProperties::default(); - let item = item_with_props(StreamProperties::Series(Box::new(series))); - assert!(!has_probe_details(&item)); - } - - #[test] - fn collect_effective_skip_clusters_uses_input_skip_flags() { - use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions}; - let input = ConfigInput { - name: "skip_live".intern(), - input_type: InputType::Xtream, - options: Some(ConfigInputOptions { - flags: ConfigInputFlags::SkipLive.into(), - ..ConfigInputOptions::defaults().clone() - }), - ..ConfigInput::default() - }; - let skip = collect_effective_skip_clusters(&input); - assert!(skip.contains(&XtreamCluster::Live)); - assert!(!skip.contains(&XtreamCluster::Video)); - assert!(!skip.contains(&XtreamCluster::Series)); - } - - #[test] - fn filter_skipped_clusters_removes_cached_groups() { - use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions}; - let live_item = PlaylistItem { - header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Live, ..Default::default() }, - }; - let vod_item = PlaylistItem { - header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Video, ..Default::default() }, - }; - - let groups = vec![ - PlaylistGroup { - id: 1, - title: "Live".intern(), - channels: vec![live_item], - xtream_cluster: XtreamCluster::Live, - }, - PlaylistGroup { - id: 2, - title: "Vod".intern(), - channels: vec![vod_item], - xtream_cluster: XtreamCluster::Video, - }, - ]; - - let source = MemoryPlaylistSource::new(groups).into_source(); - let input = ConfigInput { - name: "skip_live".intern(), - input_type: InputType::Xtream, - options: Some(ConfigInputOptions { - flags: ConfigInputFlags::SkipLive.into(), - ..ConfigInputOptions::defaults().clone() - }), - ..ConfigInput::default() - }; - - let mut filtered = filter_skipped_clusters_from_source(source, &input); - let filtered_groups = filtered.take_groups(); - assert_eq!(filtered_groups.len(), 1); - assert_eq!(filtered_groups[0].xtream_cluster, XtreamCluster::Video); - } - - fn test_group(cluster: XtreamCluster, item_name: &str, input_name: &str) -> PlaylistGroup { - PlaylistGroup { - id: 1, - title: item_name.intern(), - xtream_cluster: cluster, - channels: vec![PlaylistItem { - header: PlaylistItemHeader { - name: item_name.intern(), - input_name: input_name.intern(), - xtream_cluster: cluster, - item_type: match cluster { - XtreamCluster::Live => PlaylistItemType::Live, - XtreamCluster::Video => PlaylistItemType::Video, - XtreamCluster::Series => PlaylistItemType::Series, - }, - ..Default::default() - }, - }], - } - } - - #[test] - fn staged_overlay_replaces_selected_clusters_and_rewrites_input_name() { - let provider_name = "provider".intern(); - let provider_groups = vec![ - test_group(XtreamCluster::Live, "provider-live", "provider"), - test_group(XtreamCluster::Video, "provider-vod", "provider"), - ]; - let staged_groups = vec![ - test_group(XtreamCluster::Live, "staged-live", "staged"), - test_group(XtreamCluster::Series, "staged-series", "staged"), - ]; - - let groups = apply_staged_overlay_groups(&provider_name, ClusterFlags::Live, provider_groups, staged_groups); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].title.as_ref(), "provider-vod"); - assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider"); - assert_eq!(groups[1].title.as_ref(), "staged-live"); - assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider"); - } - - #[test] - fn staged_overlay_is_skipped_when_provider_playlist_is_cached() { - let result = PlaylistDownloadResult::new(vec![], vec![], true, false); - - assert!(!should_apply_staged_overlay(&result)); - } - - fn make_test_item(name: &str, item_type: PlaylistItemType) -> PlaylistItem { - let header = - PlaylistItemHeader { name: name.into(), group: "Test Group".intern(), item_type, ..Default::default() }; - PlaylistItem { header } - } - - #[test] - fn test_filter_evalutes_correctly() { - let filter = get_filter(r#"name ~ "Allowed""#, None).unwrap(); - - let allowed_item = make_test_item("Allowed Channel", PlaylistItemType::Live); - let denied_item = make_test_item("Denied Channel", PlaylistItemType::Live); - - let allowed_provider = ValueProvider { pli: &allowed_item, match_as_ascii: false }; - let denied_provider = ValueProvider { pli: &denied_item, match_as_ascii: false }; - - assert!(filter.filter(&allowed_provider)); - assert!(!filter.filter(&denied_provider)); - } - - #[test] - fn test_filter_with_type_comparison() { - let filter = get_filter("type = vod", None).unwrap(); - - let vod_item = make_test_item("Test Movie", PlaylistItemType::Video); - let live_item = make_test_item("Test Channel", PlaylistItemType::Live); - - let vod_provider = ValueProvider { pli: &vod_item, match_as_ascii: false }; - let live_provider = ValueProvider { pli: &live_item, match_as_ascii: false }; - - assert!(filter.filter(&vod_provider)); - assert!(!filter.filter(&live_provider)); - } - - #[test] - fn playlist_retention_reports_filter_counts() { - let groups = vec![PlaylistGroup { - id: 1, - title: "Test Group".intern(), - channels: vec![ - make_test_item("Allowed", PlaylistItemType::Live), - make_test_item("Denied", PlaylistItemType::Live), - ], - xtream_cluster: XtreamCluster::Live, - }]; - let mut source = MemoryPlaylistSource::new(groups).into_source(); - - let (filtered, outcome) = retain_playlist_items(&mut source, |item| item.header.name.as_ref() == "Allowed"); - - assert_eq!(outcome, FilterOutcome { inspected: 2, retained: 1, removed: 1 }); - assert_eq!(filtered.expect("one item should remain")[0].channels[0].header.name.as_ref(), "Allowed"); - } - - #[test] - fn filter_stage_can_remove_every_item() { - let groups = vec![PlaylistGroup { - id: 1, - title: "Test Group".intern(), - channels: vec![make_test_item("Denied", PlaylistItemType::Live)], - xtream_cluster: XtreamCluster::Live, - }]; - let mut target = ConfigTarget::from(&ConfigTargetDto::default()); - target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into(); - - let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); - - assert!(groups.is_empty()); - assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 1, retained: 0, removed: 1 })); - } - - #[test] - fn missing_processing_filter_skips_filter_stage() { - let groups = vec![PlaylistGroup { - id: 1, - title: "Test Group".intern(), - channels: vec![make_test_item("Allowed", PlaylistItemType::Live)], - xtream_cluster: XtreamCluster::Live, - }]; - let target = ConfigTarget::from(&ConfigTargetDto::default()); - - let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); - - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].channels.len(), 1); - assert!(outcome.filter.is_none()); - } - - #[test] - fn missing_processing_filter_preserves_filter_stage_group_normalization() { - let mut first = make_test_item("One", PlaylistItemType::Live); - first.header.group = "News".intern(); - let mut second = make_test_item("Two", PlaylistItemType::Live); - second.header.group = "news".intern(); - let groups = vec![ - PlaylistGroup { id: 1, title: "News".intern(), channels: vec![first], xtream_cluster: XtreamCluster::Live }, - PlaylistGroup { - id: 2, - title: "news".intern(), - channels: vec![second], - xtream_cluster: XtreamCluster::Live, - }, - ]; - let target = ConfigTarget::from(&ConfigTargetDto::default()); - - let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); - - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].channels.len(), 2); - assert!(outcome.filter.is_none()); - } - - #[test] - fn pipeline_reports_filter_and_rename_outcomes() { - let groups = vec![PlaylistGroup { - id: 1, - title: "Test Group".intern(), - channels: vec![ - make_test_item("Allowed", PlaylistItemType::Live), - make_test_item("Denied", PlaylistItemType::Live), - ], - xtream_cluster: XtreamCluster::Live, - }]; - let mut target = ConfigTarget::from(&ConfigTargetDto::default()); - target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into(); - target.rename = Some(vec![ConfigRename::from(&ConfigRenameDto { - field: ItemField::Name, - pattern: "Allowed".to_string(), - new_name: "Renamed".to_string(), - t_pattern: None, - })]); - - let (groups, outcome) = - execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter, TransformStage::Rename]); - - assert_eq!(groups[0].channels[0].header.name.as_ref(), "Renamed"); - assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 2, retained: 1, removed: 1 })); - assert_eq!(outcome.rename, Some(RenameOutcome { inspected: 1, changed_items: 1, changed_fields: 1 })); - } - - #[test] - fn assign_channel_no_playlist_preserves_non_zero_chno() { - let mut groups = vec![ - PlaylistGroup { - id: 1, - title: "Group A".intern(), - channels: vec![ - PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 10, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, - ], - xtream_cluster: XtreamCluster::Live, - }, - PlaylistGroup { - id: 2, - title: "Group C".intern(), - channels: vec![ - PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 1, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } }, - ], - xtream_cluster: XtreamCluster::Live, - }, - ]; - - assign_channel_no_playlist(&mut groups); - - // Non-zero chno values must be preserved - assert_eq!(groups[0].channels[0].header.chno, 10); - assert_eq!(groups[1].channels[0].header.chno, 1); - } - - #[test] - fn assign_channel_no_playlist_assigns_zero_chno_only() { - let mut groups = vec![PlaylistGroup { - id: 1, - title: "Group A".intern(), - channels: vec![ - PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() } }, - ], - xtream_cluster: XtreamCluster::Live, - }]; - - assign_channel_no_playlist(&mut groups); - - // All zero-chno channels should get assigned numbers starting at 1 - assert_eq!(groups[0].channels[0].header.chno, 1); - assert_eq!(groups[0].channels[1].header.chno, 2); - assert_eq!(groups[0].channels[2].header.chno, 3); - } - - #[test] - fn assign_channel_no_playlist_skips_existing_nonzero_numbers() { - let mut groups = vec![PlaylistGroup { - id: 1, - title: "Group A".intern(), - channels: vec![ - PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 5, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 2, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } }, - ], - xtream_cluster: XtreamCluster::Live, - }]; - - assign_channel_no_playlist(&mut groups); - - // Existing non-zero numbers (2, 5) must be skipped when assigning new numbers - assert_eq!(groups[0].channels[0].header.chno, 5); // preserved - assert_eq!(groups[0].channels[2].header.chno, 2); // preserved - // B gets 1 (smallest available), D gets 3 (next available after 1 and existing 2) - assert_eq!(groups[0].channels[1].header.chno, 1); - assert_eq!(groups[0].channels[3].header.chno, 3); - } - - #[test] - fn assign_channel_no_playlist_assigns_following_group_order() { - let mut groups = vec![ - PlaylistGroup { - id: 1, - title: "Group 1".intern(), - channels: vec![ - PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } }, - PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, - ], - xtream_cluster: XtreamCluster::Live, - }, - PlaylistGroup { - id: 2, - title: "Group 2".intern(), - channels: vec![PlaylistItem { - header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() }, - }], - xtream_cluster: XtreamCluster::Live, - }, - ]; - - assign_channel_no_playlist(&mut groups); - - // Numbers should follow iteration order across groups: A=1, B=2, C=3 - assert_eq!(groups[0].channels[0].header.chno, 1); - assert_eq!(groups[0].channels[1].header.chno, 2); - assert_eq!(groups[1].channels[0].header.chno, 3); - } - - #[tokio::test] - async fn parallel_input_scheduler_serializes_equal_groups_and_overlaps_distinct_groups() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - async fn observe(active: &AtomicUsize, maximum: &AtomicUsize) { - let current = active.fetch_add(1, Ordering::SeqCst) + 1; - maximum.fetch_max(current, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(20)).await; - active.fetch_sub(1, Ordering::SeqCst); - } - - let locks = tuliprox_core::utils::FileLockManager::default(); - let active = AtomicUsize::new(0); - let maximum = AtomicUsize::new(0); - tokio::join!( - with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), - with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), - ); - assert_eq!(maximum.load(Ordering::SeqCst), 1); - - maximum.store(0, Ordering::SeqCst); - tokio::join!( - with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), - with_sequential_group(&locks, Some(8), true, observe(&active, &maximum)), - ); - assert_eq!(maximum.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn parallel_input_scheduler_releases_group_after_abort() { - let locks = Arc::new(tuliprox_core::utils::FileLockManager::default()); - let task_locks = Arc::clone(&locks); - let task = tokio::spawn(async move { - with_sequential_group(&task_locks, Some(7), true, std::future::pending::<()>()).await; - }); - tokio::task::yield_now().await; - task.abort(); - let _ = task.await; - - tokio::time::timeout( - Duration::from_secs(1), - with_sequential_group(&locks, Some(7), true, std::future::ready(())), - ) - .await - .expect("aborting an input job must release its sequential group"); - } - - #[test] - fn input_progress_message_contains_each_target_and_blocking_input() { - let targets = ["target-a", "target-b"]; - let inputs = ["input-a", "input-b"]; - let messages: Vec<_> = targets - .iter() - .flat_map(|target| inputs.iter().map(move |input| target_waiting_message(target, input))) - .collect(); - - assert_eq!(messages.len(), 4); - for target in targets { - for input in inputs { - assert!(messages.contains(&format!("Target '{target}' is waiting for input '{input}'"))); - } - } - assert!(stalker_checkpoint_message("portal-a").contains("portal-a")); - } - - #[tokio::test] - async fn parallel_target_pipeline_bounds_active_finalizers() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - let active = Arc::new(AtomicUsize::new(0)); - let maximum = Arc::new(AtomicUsize::new(0)); - let mut tasks = JoinSet::new(); - let mut results = Vec::new(); - let mut errors = Vec::new(); - - for index in 0..6 { - wait_for_target_finalizer_slot(&mut tasks, &mut results, &mut errors).await; - let active = Arc::clone(&active); - let maximum = Arc::clone(&maximum); - tasks.spawn(async move { - let current = active.fetch_add(1, Ordering::SeqCst) + 1; - maximum.fetch_max(current, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(10)).await; - active.fetch_sub(1, Ordering::SeqCst); - TargetJobResult { - index, - name: format!("target-{index}"), - result: Ok(()), - errors: Vec::new(), - processing: PipelineStats::default(), - } - }); - } - while let Some(result) = tasks.join_next().await { - collect_target_task_result(result, &mut results, &mut errors); - } - - assert!(maximum.load(Ordering::SeqCst) <= MAX_CONCURRENT_TARGET_FINALIZERS); - assert_eq!(results.len(), 6); - assert!(errors.is_empty()); - } - - #[test] - fn parallel_target_pipeline_normalizes_conflicting_output_resources() { - let config = Config { storage_dir: "/tmp/tuliprox-target-resources".to_string(), ..Config::default() }; - - let mut spaced = ConfigTarget::from(&ConfigTargetDto::default()); - spaced.name = "A B".to_string(); - let mut underscored = ConfigTarget::from(&ConfigTargetDto::default()); - underscored.name = "A_B".to_string(); - assert!( - !target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)) - ); - - spaced.name = "one".to_string(); - spaced.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput { - filename: Some("out/../x.m3u".to_string()), - include_type_in_url: false, - mask_redirect_url: false, - filter: None, - })]; - underscored.name = "two".to_string(); - underscored.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput { - filename: Some("x.m3u".to_string()), - include_type_in_url: false, - mask_redirect_url: false, - filter: None, - })]; - assert!( - !target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored)) - ); - } - - mod mapping_stage { - use super::*; - use arc_swap::{ArcSwap, ArcSwapOption}; - use shared::model::{ConfigPaths, EpgSmartMatchConfigDto}; - use std::sync::Arc; - use tempfile::tempdir; - use tokio::runtime::Runtime; - use tuliprox_core::{ - model::{ - EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, MediaToolCapabilities, PersistedEpgSource, - PersistedEpgSourceKind, SourcesConfig, - }, - utils::FileLockManager, - }; - - fn build_mapping(id: &str, stage: MappingStage, script: &str) -> CompiledMapping { - CompiledMapping { - id: id.to_string(), - match_as_ascii: false, - stage, - rules: vec![CompiledMappingRule { - name: None, - filter: get_filter(r#"name ~ ".*""#, None).expect("filter parses"), - program: MappingProgram::Script(MapperScript::parse(script, None).expect("script parses")), - }], - counters: vec![], - templates: None, - } - } - - fn build_target(mappings: Vec, remove_duplicates: bool) -> ConfigTarget { - let dto = ConfigTargetDto { - options: if remove_duplicates { - Some(ConfigTargetOptions { remove_duplicates, ..Default::default() }) - } else { - None - }, - ..Default::default() - }; - let mut target = ConfigTarget::from(&dto); - target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(CompiledTargetMappings::new( - mappings.into_iter().map(Arc::new).collect(), - ))))); - target - } - - /// Pinned to `NoopSink` rather than staying generic: these tests - /// exercise the pipeline, not the bus, and an inferred sink type - /// would just make every call site name one. - fn processing_context() -> PlaylistProcessingContext { - let paths = ConfigPaths { - home_path: String::new(), - config_path: String::new(), - storage_path: String::new(), - config_file_path: String::new(), - sources_file_path: String::new(), - mapping_file_path: None, - mapping_files_used: None, - template_file_path: None, - template_files_used: None, - api_proxy_file_path: String::new(), - custom_stream_response_path: None, - }; - let config = AppConfig { - config: Arc::new(ArcSwap::from_pointee(Config::default())), - sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), - hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::default()), - file_locks: Arc::new(FileLockManager::default()), - paths: Arc::new(ArcSwap::from_pointee(paths)), - custom_stream_response: Arc::new(ArcSwapOption::default()), - access_token_secret: [0; 32], - encrypt_secret: [0; 16], - media_tools: Arc::new(MediaToolCapabilities::new()), - }; - PlaylistProcessingContext { - client: reqwest::Client::new(), - config: Arc::new(config), - user_targets: Arc::new(ProcessTargets { - enabled: false, - inputs: Vec::new(), - targets: Vec::new(), - target_names: Vec::new(), - }), - events: shared::model::NoopSink, - playlist_state: None, - disabled_headers: None, - processed_inputs: Arc::new(Mutex::new(HashSet::new())), - input_locks: Arc::new(Mutex::new(HashMap::new())), - provider_manager: None, - metadata_manager: None, - pre_processed_inputs: None, - stalker_refresh_mode: StalkerRefreshMode::Complete, - partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } - - #[test] - fn persist_filter_runs_after_after_epg_mapping() { - let runtime = Runtime::new().expect("runtime"); - runtime.block_on(async { - let mut input = ConfigInput::from(ConfigInputDto::default()); - input.name = "input".intern(); - let groups = vec![PlaylistGroup { - id: 1, - title: "Live".intern(), - channels: vec![PlaylistItem { - header: PlaylistItemHeader { - name: "Before".intern(), - group: "Live".intern(), - xtream_cluster: XtreamCluster::Live, - item_type: PlaylistItemType::Live, - ..Default::default() - }, - }], - xtream_cluster: XtreamCluster::Live, - }]; - let mut playlist = FetchedPlaylist { - input: &input, - source: MemoryPlaylistSource::new(groups).into_source(), - epg: None, - }; - let rename = build_mapping("rename", MappingStage::AfterEpg, r#"@Name = "After""#); - let mut target = build_target(vec![rename], false); - target.filter.persist = Some(get_filter(r#"Name = "After""#, None).expect("filter parses")); - let mut stats = HashMap::from([( - Arc::clone(&input.name), - create_input_stat(1, 1, 0, input.input_type, &input.name, 0), - )]); - let mut errors = Vec::new(); - - let mut prepared = prepare_playlist_for_target( - &processing_context(), - std::slice::from_mut(&mut playlist), - &target, - &mut stats, - &mut errors, - false, - ) - .await - .expect("target preparation"); - - assert!(errors.is_empty()); - apply_persist_filter(&target, &mut prepared.playlist); - let item = &prepared.playlist[0].channels[0]; - assert_eq!(item.header.name.as_ref(), "After"); - }); - } - - fn make_channel(name: &str) -> PlaylistItem { - let mut item = PlaylistItem { - header: PlaylistItemHeader { - name: name.intern(), - group: "Originals".intern(), - xtream_cluster: XtreamCluster::Live, - item_type: PlaylistItemType::Live, - ..Default::default() - }, - }; - item.header.freeze_input_stream_id(); - item - } - - fn memory_source(channels: Vec) -> PlaylistSource { - MemoryPlaylistSource::new(vec![PlaylistGroup { - id: 1, - title: "Live".intern(), - channels, - xtream_cluster: XtreamCluster::Live, - }]) - .into_source() - } - - fn channel_count(source: &mut PlaylistSource) -> usize { - source.take_groups().iter().map(|g| g.channels.len()).sum() - } - - #[test] - fn map_playlist_applies_only_the_requested_stage() { - let processing = build_mapping("processing", MappingStage::Processing, r#"@name = concat(@Name, "-P")"#); - let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"@name = concat(@Name, "-E")"#); - let target = build_target(vec![processing, after_epg], false); - - let mut source = memory_source(vec![make_channel("Alpha")]); - let (groups, _) = execute_pipeline_on_groups(source.take_groups(), &target, &[TransformStage::Map]); - assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P"); - - let mut source = MemoryPlaylistSource::new(groups).into_source(); - let groups = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None) - .expect("after_epg mapping should run"); - assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P-E"); - } - - #[test] - fn map_playlist_at_stage_returns_none_without_consuming_source_when_no_match() { - let target = build_target(Vec::new(), false); - let mut source = memory_source(vec![make_channel("Alpha")]); - - let result = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None); - assert!(result.is_none(), "no matching stage must return None"); - assert_eq!(channel_count(&mut source), 1, "source must remain intact"); - } - - #[test] - fn prepare_target_applies_after_epg_mapping_before_sampling_stats() { - let runtime = Runtime::new().expect("runtime"); - runtime.block_on(async { - let dir = tempdir().expect("tempdir"); - let ics_path = dir.path().join("bbc.ics"); - std::fs::write( - &ics_path, - "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR", - ) - .expect("write ics"); - - let mut smart_dto = EpgSmartMatchConfigDto { - enabled: true, - fuzzy_matching: false, - ..EpgSmartMatchConfigDto::default() - }; - smart_dto.prepare().expect("smart config"); - let mut input = ConfigInput::from(ConfigInputDto::default()); - input.name = "input".intern(); - input.epg = Some(EpgConfig { - sources: vec![], - smart_match: Some(EpgSmartMatchConfig::from(smart_dto)), - }); - - let channels = vec![live_item_for_epg("BBC One")]; - let groups = vec![PlaylistGroup { - id: 1, - title: "Live".intern(), - channels, - xtream_cluster: XtreamCluster::Live, - }]; - let tv_guide = TVGuide::new(vec![PersistedEpgSource { - file_path: ics_path, - priority: 0, - logo_override: false, - kind: PersistedEpgSourceKind::Ics { - channel_id: "bbc.one".intern(), - channel_title: Some("BBC One".intern()), - match_names: vec!["BBC One".intern()], - config: Box::new(IcsEpgSourceConfig::default()), - }, - }]); - - let mut playlist = FetchedPlaylist { - input: &input, - source: MemoryPlaylistSource::new(groups).into_source(), - epg: Some(tv_guide), - }; - - let rename_from_epg = build_mapping( - "rename", - MappingStage::AfterEpg, - r#"epg = @epg_channel_id ~ "(.+)" -match { - epg => @Name = epg.1 -}"#, - ); - let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); - let target = build_target(vec![rename_from_epg, add_virtual], false); - let mut stats = HashMap::from([( - Arc::clone(&input.name), - create_input_stat(1, 1, 0, input.input_type, &input.name, 0), - )]); - let mut errors = Vec::new(); - let prepared = prepare_playlist_for_target( - &processing_context(), - std::slice::from_mut(&mut playlist), - &target, - &mut stats, - &mut errors, - false, - ) - .await - .expect("target preparation"); - - assert!(errors.is_empty()); - assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::(), 2); - let channel = prepared - .playlist - .iter() - .flat_map(|group| &group.channels) - .find(|channel| channel.header.group.as_ref() != "Echo") - .expect("original channel"); - assert_eq!(channel.header.epg_channel_id.as_deref(), Some("bbc.one")); - assert_eq!( - channel.header.name.as_ref(), - "bbc.one", - "after_epg mapper must consume the EPG-enriched field" - ); - let processed_stats = &stats[&input.name].processed_stats; - assert_eq!(processed_stats.group_count, 2); - assert_eq!(processed_stats.channel_count, 2); - }); - } - - #[test] - fn clear_invalid_epg_ids_clears_ids_invalidated_by_after_epg_mapping() { - let runtime = Runtime::new().expect("runtime"); - runtime.block_on(async { - let dir = tempdir().expect("tempdir"); - let ics_path = dir.path().join("bbc.ics"); - std::fs::write( - &ics_path, - "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR", - ) - .expect("write ics"); - - let mut input = ConfigInput::from(ConfigInputDto::default()); - input.name = "input".intern(); - input.epg = Some(EpgConfig { sources: vec![], smart_match: None }); - let groups = vec![PlaylistGroup { - id: 1, - title: "Live".intern(), - channels: vec![PlaylistItem { - header: PlaylistItemHeader { - name: "BBC One".intern(), - epg_channel_id: Some("bbc.one".intern()), - group: "Live".intern(), - xtream_cluster: XtreamCluster::Live, - item_type: PlaylistItemType::Live, - ..Default::default() - }, - }], - xtream_cluster: XtreamCluster::Live, - }]; - let tv_guide = TVGuide::new(vec![PersistedEpgSource { - file_path: ics_path, - priority: 0, - logo_override: false, - kind: PersistedEpgSourceKind::Ics { - channel_id: "bbc.one".intern(), - channel_title: Some("BBC One".intern()), - match_names: vec![], - config: Box::new(IcsEpgSourceConfig::default()), - }, - }]); - let mut playlist = FetchedPlaylist { - input: &input, - source: MemoryPlaylistSource::new(groups).into_source(), - epg: Some(tv_guide), - }; - - let rewrite_epg = - build_mapping("rewrite", MappingStage::AfterEpg, r#"@epg_channel_id = "missing.epg""#); - let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); - let mut target = build_target(vec![rewrite_epg, add_virtual], false); - target.options = Some(ConfigTargetOptions { clear_invalid_epg_ids: true, ..Default::default() }); - let mut stats = HashMap::from([( - Arc::clone(&input.name), - create_input_stat(1, 1, 0, input.input_type, &input.name, 0), - )]); - let mut errors = Vec::new(); - - let prepared = prepare_playlist_for_target( - &processing_context(), - std::slice::from_mut(&mut playlist), - &target, - &mut stats, - &mut errors, - false, - ) - .await - .expect("target preparation"); - - assert!(errors.is_empty()); - assert!(!prepared.playlist.is_empty()); - assert!(prepared - .playlist - .iter() - .flat_map(|group| &group.channels) - .all(|channel| channel.header.epg_channel_id.is_none())); - assert_eq!(stats[&input.name].processed_stats.channel_count, 2); - }); - } - - fn live_item_for_epg(name: &str) -> PlaylistItem { - PlaylistItem { - header: PlaylistItemHeader { - name: name.intern(), - group: "Live".intern(), - xtream_cluster: XtreamCluster::Live, - item_type: PlaylistItemType::Live, - ..Default::default() - }, - } - } - - #[test] - fn after_epg_hook_runs_on_source_already_deduplicated_by_processing_pipe() { - let processing = build_mapping("processing", MappingStage::Processing, r#"@group = "PROCESSED""#); - let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); - let target = build_target(vec![processing, after_epg], true); - - let input = ConfigInput::default(); - let channel = make_channel("Alpha"); - let mut fetched = - FetchedPlaylist { input: &input, source: memory_source(vec![channel.clone(), channel]), epg: None }; - let mut duplicates = HashSet::new(); - let (mut processed, _outcome) = - execute_pipe(&target, &get_processing_pipe(&target), &mut fetched, &mut duplicates, false) - .expect("processing pipe must run"); - assert_eq!(processed.get_channel_count(), 1, "processing pipe must remove the duplicate"); - - let groups = map_playlist_at_stage(&mut processed.source, &target, MappingStage::AfterEpg, None) - .expect("after_epg hook must run"); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].title.as_ref(), "PROCESSED"); - assert_eq!(groups[0].channels.len(), 1); - assert_eq!(groups[1].title.as_ref(), "Echo"); - assert_eq!(groups[1].channels.len(), 1); - } - - #[test] - fn prepare_target_deduplicates_virtual_items_created_by_after_epg_mappings() { - let runtime = Runtime::new().expect("runtime"); - runtime.block_on(async { - let first = build_mapping("first", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); - let second = build_mapping("second", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); - let target = build_target(vec![first, second], true); - let input = ConfigInput { name: "input".intern(), ..Default::default() }; - let mut playlist = - FetchedPlaylist { input: &input, source: memory_source(vec![make_channel("Alpha")]), epg: None }; - let mut stats = HashMap::from([( - Arc::clone(&input.name), - create_input_stat(1, 1, 0, input.input_type, &input.name, 0), - )]); - let mut errors = Vec::new(); - - let prepared = prepare_playlist_for_target( - &processing_context(), - std::slice::from_mut(&mut playlist), - &target, - &mut stats, - &mut errors, - false, - ) - .await - .expect("target preparation"); - - assert!(errors.is_empty()); - assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::(), 3); - assert_eq!(stats[&input.name].processed_stats.channel_count, 3); - }); - } - } -} - -#[cfg(test)] -mod disk_epg_wireup_tests { - use super::spill_epg_to_disk; - use shared::model::EpgChannel; - use std::sync::Arc; - use tuliprox_core::model::Epg; - - /// Build an `Epg` with `channel_count` channels whose ids follow the - /// `id_base` prefix. Two sources built with the same `id_base` and - /// `channel_count` will share all channel ids, which is what we need to - /// exercise the priority-override `Occupied` branch in - /// `EpgMergeAccumulator::upsert_channel`. - fn build_epg(id_base: &str, priority: i16, channel_count: usize) -> Epg { - Epg { - priority, - logo_override: false, - attributes: None, - children: (0..channel_count) - .map(|i| { - let id: Arc = format!("{id_base}-ch-{i:04}").into(); - Arc::new(EpgChannel { - id: Arc::clone(&id), - title: Some(format!("title-{priority}-{i}").into()), - icon: None, - programmes: vec![shared::model::EpgProgramme::new( - i64::try_from(i).expect("test index fits in i64"), - i64::try_from(i + 1).expect("test index fits in i64"), - id, - )], - }) - }) - .collect(), - } - } - - /// Wire-up regression guard: `spill_epg_to_disk` is the function called - /// by `finalize_prepared_target` when `disk_based_processing = true`. It - /// must (a) preserve per-source priority on shared channels, (b) clean up - /// its temp files, and (c) merge into a single `Epg` of the right size. - /// - /// Both sources share channel ids (`shared-ch-NNNN`), forcing the merge - /// to take the `Occupied` branch in `EpgMergeAccumulator::upsert_channel`. - /// The lower-priority source (priority 3) must win, the higher-priority - /// (priority 7) must be discarded for shared ids. Without this assertion - /// the test would pass even if priority resolution were broken — the - /// earlier version used unique ids and therefore never hit the merge path. - #[test] - fn spill_epg_to_disk_merges_shared_channels_by_priority() { - let epg_low = build_epg("shared", 3, 50); // wins on every shared channel - let epg_high = build_epg("shared", 7, 50); // discarded on every shared channel - - let merged = spill_epg_to_disk(vec![epg_low, epg_high]) - .expect("disk merge returned an error") - .expect("merged Epg is unexpectedly None for two non-empty sources"); - - // 50 distinct channels, not 100 — the merge must have collapsed the - // shared ids. - assert_eq!(merged.children.len(), 50, "shared channel ids must collapse to one entry, not be duplicated"); - - // Every channel title comes from the lower-priority source. If the - // merge logic is wrong, some titles will carry the "-7-" marker. - for ch in &merged.children { - let title = ch.title.as_deref().expect("title preserved through merge"); - assert!( - title.starts_with("title-3-"), - "channel {:?} kept title {title:?} from higher-priority source; \ - priority override is broken", - ch.id, - ); - // `add_channel_with_programmes` on the disk-merge path must - // preserve the lower-priority source's single programme per - // channel — `upsert_channel` would silently drop them. - assert_eq!(ch.programmes.len(), 1, "channel {:?} lost programmes through the disk-merge path", ch.id); - let prog = &ch.programmes[0]; - assert!(prog.title.is_none() || prog.title.as_deref() != Some("title-7")); - } - } - - /// The non-shared case: sources with disjoint channel ids. Both - /// sources' channels appear in the result with no priority loss (no - /// `Occupied` branch is taken). - #[test] - fn spill_epg_to_disk_keeps_disjoint_sources_intact() { - let epg_low = build_epg("src-a", 3, 50); - let epg_high = build_epg("src-b", 7, 50); - - let merged = spill_epg_to_disk(vec![epg_low, epg_high]) - .expect("disk merge returned an error") - .expect("merged Epg is unexpectedly None for two non-empty sources"); - - assert_eq!(merged.children.len(), 100, "disjoint ids must not collapse"); - assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-3-0"))); - assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-7-0"))); - } - - #[test] - fn spill_epg_to_disk_returns_none_for_empty_input() { - let merged = spill_epg_to_disk(vec![]).expect("disk merge returned an error"); - assert!(merged.is_none()); - } -} diff --git a/backend/processing/src/processor/playlist/ingest.rs b/backend/processing/src/processor/playlist/ingest.rs new file mode 100644 index 000000000..a6c5ad9c7 --- /dev/null +++ b/backend/processing/src/processor/playlist/ingest.rs @@ -0,0 +1,831 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +// Inputs disabled in the config are always disabled. +// Command-line targets can only restrict enabled inputs, never enable them. +pub(crate) fn is_input_enabled(input: &ConfigInput, user_targets: &ProcessTargets) -> bool { + input.enabled && (!user_targets.enabled || user_targets.has_input(input.id)) +} + +pub(crate) async fn with_sequential_group( + file_locks: &tuliprox_core::utils::FileLockManager, + group: Option, + process_parallel: bool, + future: impl std::future::Future, +) -> T { + let _guard = if process_parallel { + if let Some(group) = group { + Some(file_locks.write_lock_str(&format!("sequential_group:{group}")).await) + } else { + None + } + } else { + None + }; + future.await +} + +pub(crate) struct PlaylistDownloadResult { + pub downloaded_playlist: Vec, + pub download_err: Vec, + pub was_cached: bool, + pub persisted: bool, + pub partial: bool, +} + +impl PlaylistDownloadResult { + pub fn new( + downloaded_playlist: Vec, + download_err: Vec, + was_cached: bool, + persisted: bool, + ) -> Self { + Self { downloaded_playlist, download_err, was_cached, persisted, partial: false } + } + + pub(crate) fn with_partial(mut self, partial: bool) -> Self { + self.partial = partial; + self + } +} + +pub(crate) fn collect_effective_skip_clusters(input: &ConfigInput) -> Vec { + if !input.input_type.is_xtream() { + return vec![]; + } + xtream::get_skip_cluster(input) +} + +pub(crate) fn filter_skipped_clusters_from_source(source: PlaylistSource, input: &ConfigInput) -> PlaylistSource { + let skip_clusters = collect_effective_skip_clusters(input); + if skip_clusters.is_empty() { + return source; + } + + let skip_set: HashSet = skip_clusters.into_iter().collect(); + PlaylistSource::filtered(source, skip_set) +} + +pub(crate) fn cluster_selected(cluster: XtreamCluster, clusters: ClusterFlags) -> bool { + match cluster { + XtreamCluster::Live => clusters.contains(ClusterFlags::Live), + XtreamCluster::Video => clusters.contains(ClusterFlags::Vod), + XtreamCluster::Series => clusters.contains(ClusterFlags::Series), + } +} + +pub(crate) fn apply_staged_overlay_groups( + provider_name: &Arc, + clusters: ClusterFlags, + provider_groups: Vec, + staged_groups: Vec, +) -> Vec { + let mut groups: Vec = + provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)).collect(); + + groups.extend(staged_groups.into_iter().filter(|group| cluster_selected(group.xtream_cluster, clusters)).map( + |mut group| { + for item in &mut group.channels { + item.header.input_name = Arc::clone(provider_name); + } + group + }, + )); + + groups +} + +pub(crate) fn should_apply_staged_overlay(download_result: &PlaylistDownloadResult) -> bool { + !download_result.was_cached +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn playlist_download_from_input( + client: &reqwest::Client, + app_config: &Arc, + events: &E, + input: &ConfigInput, + stalker_refresh_mode: StalkerRefreshMode, +) -> PlaylistDownloadResult { + let config = &*app_config.config.load(); + let storage_dir = &config.storage_dir; + + // Check Status + let storage_path = input_cache::resolve_input_storage_path(storage_dir, &input.name).await; + let mut status = input_cache::load_input_status(&storage_path); + let cache_duration = input.cache_duration_seconds; + + // Ensure data directory exists + match tokio::fs::try_exists(&storage_path).await { + Ok(false) => { + if let Err(err) = tokio::fs::create_dir_all(&storage_path).await { + warn!("Failed to create input storage directory '{}': {err}", storage_path.display()); + } + } + Err(err) => { + warn!("Failed to check existence of input storage directory '{}': {err}", storage_path.display()); + } + Ok(true) => {} + } + + let download_input_type = input.get_download_input_type(); + // Use per-cluster cache for effective Xtream downloads. + let use_per_cluster_cache = download_input_type.is_xtream(); + + let mut xtream_clusters_to_download = Vec::new(); + let fully_cached = if use_per_cluster_cache { + let skip_cluster = collect_effective_skip_clusters(input); + let xtream_cache_candidates = xtream::requested_clusters(None, &skip_cluster); + + for cluster in xtream_cache_candidates { + if !input_cache::is_cache_valid(&status, cluster.as_ref(), cache_duration) { + xtream_clusters_to_download.push(cluster); + } + } + + xtream_clusters_to_download.is_empty() + } else { + input_cache::is_cache_valid(&status, "default", cache_duration) + }; + + if fully_cached { + return PlaylistDownloadResult::new(vec![], vec![], true, false); + } + + let request = PlaylistFetchRequest { + app_config, + config: &app_config.config.load(), + client, + input, + xtream_clusters: Some(xtream_clusters_to_download.as_slice()), + }; + + // Each arm builds the provider its input type needs and awaits it in place: the + // provider types share no supertype, and building one is free, so this stays a match + // and stays statically dispatched. What changed is the result - one named + // `PlaylistFetch` instead of a six-element tuple assembled by position. + let fetch = match download_input_type { + InputType::M3u => M3uProvider.fetch(&request).await, + InputType::Xtream => XtreamProvider::new(events).fetch(&request).await, + InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch => { + BatchContainerProvider.fetch(&request).await + } + InputType::Stalker => { + StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await + } + InputType::Library => LibraryProvider.fetch(&request).await, + InputType::Plex => PlexProvider.fetch(&request).await, + InputType::Emby | InputType::Jellyfin => { + UnsupportedProvider::new( + "media-server", + format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name), + ) + .fetch(&request) + .await + } + InputType::Staged => { + UnsupportedProvider::new( + "staged", + format!("staged input '{}' was not resolved against a parent input", input.name), + ) + .fetch(&request) + .await + } + }; + // `ProviderErrorKind` has always been able to answer "is this worth + // retrying, and does it need a human" - `needs_operator()` is exactly that + // question - and nothing consumed the answer. Every fetch failure was + // counted, logged and treated identically. + if let Some(kind) = fetch.error_kind() { + let worst = fetch + .errors + .iter() + .max_by_key(|error| ProviderErrorKind::of_tuliprox(error)) + .map(|error| sanitize_sensitive_info(&error.to_string()).into_owned()); + events.emit(EventMessage::ProviderFetchFailed(ProviderFetchFailure { + input: sanitize_sensitive_info(&input.name).into_owned().into(), + provider: download_input_type.to_string().into(), + kind: kind.into(), + error_count: fetch.errors.len(), + message: worst, + retryable: kind.is_retryable(), + needs_operator: kind.needs_operator(), + partial: fetch.partial, + })); + } + + let PlaylistFetch { groups: playlist, errors, persisted, partial } = fetch; + + // Update Status + let save_status; + if partial { + input_cache::update_cluster_status(&mut status, "default", ClusterState::Failed); + save_status = true; + } else if errors.is_empty() { + if use_per_cluster_cache { + for cluster in &xtream_clusters_to_download { + input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Ok); + } + save_status = !xtream_clusters_to_download.is_empty(); + } else { + input_cache::update_cluster_status(&mut status, "default", ClusterState::Ok); + save_status = true; + } + } else if use_per_cluster_cache { + for cluster in &xtream_clusters_to_download { + input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Failed); + } + save_status = !xtream_clusters_to_download.is_empty(); + } else { + input_cache::update_cluster_status(&mut status, "default", ClusterState::Failed); + save_status = true; + } + + if save_status { + input_cache::save_input_status(&storage_path, &status); + } + + PlaylistDownloadResult::new(playlist, errors, false, persisted).with_partial(partial) +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum InputJobState { + Ready, + Pending, + Failed, +} + +pub(crate) struct InputJobResult { + pub(crate) index: usize, + pub(crate) input_name: Arc, + pub(crate) state: InputJobState, + pub(crate) source: Option, + pub(crate) epg: Option, + pub(crate) stat: InputStats, + pub(crate) errors: Vec, +} + +pub(crate) async fn process_input_job( + index: usize, + ctx: &PlaylistProcessingContext, + input: &Arc, + process_parallel: bool, +) -> InputJobResult { + with_sequential_group( + &ctx.config.file_locks, + input.sequential_group, + process_parallel, + process_input_job_inner(index, ctx, input), + ) + .await +} + +pub(crate) async fn process_input_job_inner( + index: usize, + ctx: &PlaylistProcessingContext, + input: &Arc, +) -> InputJobResult { + let start_time = Instant::now(); + let input_type = input.get_download_input_type(); + let broadcast_step = create_broadcast_callback(&ctx.events); + broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); + + let (mut errors, mut source, storage_error, partial) = download_input(ctx, input, false).await; + let storage_failed = storage_error.is_some(); + if let Some(err) = storage_error { + broadcast_step("Playlist download", &format!("Failed to persist/load input '{}' playlist", input.name)); + error!("Failed to persist input playlist {}", input.name); + errors.push(err); + } + let epg = if input_type == InputType::Library || partial || storage_failed { + None + } else { + download_input_epg(ctx, input, &mut errors).await + }; + let group_count = source.get_group_count(); + let channel_count = source.get_channel_count(); + let state = if partial { + InputJobState::Pending + } else if storage_failed || source.is_empty() { + if source.is_empty() { + broadcast_step("Playlist download", &format!("Input '{}' playlist is empty", input.name)); + errors.push(TuliproxError::RepositoryPlaylist(format!("Source is empty {}", input.name))); + } + InputJobState::Failed + } else { + InputJobState::Ready + }; + let stat = create_input_stat( + group_count, + channel_count, + errors.len(), + input_type, + &input.name, + start_time.elapsed().as_secs(), + ); + + InputJobResult { + index, + input_name: input.name.clone(), + state, + source: (state == InputJobState::Ready).then_some(source), + epg, + stat, + errors, + } +} + +pub(crate) fn panicked_input_job(index: usize, input: &ConfigInput) -> InputJobResult { + let error = TuliproxError::RepositoryPlaylist(format!("Input '{}' processing panicked", input.name)); + InputJobResult { + index, + input_name: input.name.clone(), + state: InputJobState::Failed, + source: None, + epg: None, + stat: create_input_stat(0, 0, 1, input.get_download_input_type(), &input.name, 0), + errors: vec![error], + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn process_source( + source_idx: usize, + ctx: Arc>, +) -> (Vec, Vec, Vec) { + log_memory_snapshot(format!("source[{source_idx}] start").as_str()); + let sources = ctx.config.sources.load(); + let mut errors = vec![]; + let mut input_stats = HashMap::, InputStats>::new(); + let mut target_stats = Vec::::new(); + if let Some(source) = sources.get_source_at(source_idx) { + let mut source_playlists = Vec::with_capacity(source.inputs.len()); + let broadcast_step = create_broadcast_callback(&ctx.events); + let process_parallel = ctx.config.config.load().process_parallel; + let mut disabled_inputs: Vec> = vec![]; + let mut enabled_inputs = Vec::with_capacity(source.inputs.len()); + for (index, input_name) in source.inputs.iter().enumerate() { + let Some(input) = sources.get_input_by_name(input_name) else { + error!("Input {input_name} referenced by source {source_idx} does not exist"); + continue; + }; + if is_input_enabled(input, &ctx.user_targets) { + enabled_inputs.push((index, input)); + } else { + disabled_inputs.push(input.name.clone()); + } + } + + let source_downloaded = !enabled_inputs.is_empty(); + let mut job_results = Vec::with_capacity(enabled_inputs.len()); + if process_parallel { + let mut jobs = futures::stream::FuturesUnordered::new(); + for &(index, input) in &enabled_inputs { + let job = std::panic::AssertUnwindSafe(process_input_job(index, &ctx, input, true)).catch_unwind(); + jobs.push(async move { + match job.await { + Ok(result) => result, + Err(_) => panicked_input_job(index, input), + } + }); + } + while let Some(result) = jobs.next().await { + job_results.push(result); + } + } else { + for &(index, input) in &enabled_inputs { + job_results.push(process_input_job(index, &ctx, input, false).await); + } + } + job_results.sort_by_key(|result| result.index); + + let mut blockers = Vec::new(); + for mut result in job_results { + errors.append(&mut result.errors); + input_stats.insert(result.input_name.clone(), result.stat); + if result.state == InputJobState::Ready { + if let (Some(input), Some(source)) = + (sources.get_input_by_name(&result.input_name), result.source.take()) + { + source_playlists.push(FetchedPlaylist { input, source, epg: result.epg }); + } + } else { + blockers.push(result.input_name); + } + } + + if !disabled_inputs.is_empty() && !source_downloaded { + warn!( + "Source at index {source_idx} has no enabled inputs for the given targets. Disabled: {}", + join_arc_strs(&disabled_inputs, ", ") + ); + } + if source_downloaded { + if !blockers.is_empty() { + for target in source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)) { + for input_name in &blockers { + broadcast_step("Playlist download", &target_waiting_message(&target.name, input_name)); + } + } + } else if source_playlists.is_empty() { + debug!("Source at index {source_idx} is empty"); + errors.push(TuliproxError::RepositoryPlaylist(format!( + "Source at index {source_idx} is empty: {}", + join_arc_strs(&source.inputs, ", ") + ))); + } else { + debug_if_enabled!( + "Source has {} groups", + source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::() + ); + let enabled_targets: Vec<_> = + source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)).collect(); + target_stats = process_targets( + &ctx, + &mut source_playlists, + &enabled_targets, + &mut input_stats, + &mut errors, + process_parallel, + ) + .await; + } + } + } + log_memory_snapshot(format!("source[{source_idx}] end").as_str()); + let ordered_input_stats = sources + .get_source_at(source_idx) + .map_or_else(Vec::new, |source| source.inputs.iter().filter_map(|name| input_stats.remove(name)).collect()); + (ordered_input_stats, target_stats, errors) +} + +pub(crate) async fn download_input_epg( + ctx: &PlaylistProcessingContext, + input: &Arc, + error_list: &mut Vec, +) -> Option { + // A failed playlist download makes the EPG moot: the channels it would annotate are + // not there. + if !error_list.is_empty() { + return None; + } + let provider = XmltvEpgProvider::new(ctx); + // The XMLTV path produces documents, not programme records, so nothing reaches the + // sink. It is here because the same call answers for a record-streaming provider. + let mut discarded = CountingEpgSink::new(); + let outcome = provider.fetch(&EpgFetchRequest::new(input), &mut discarded).await; + error_list.extend(provider.take_errors()); + match outcome { + Ok(outcome) => outcome.into_guide(), + Err(err) => { + error_list.push(err); + None + } + } +} + +/// `invalidate_input_cache_status` performs a non-atomic file I/O sequence +/// (`input_cache::load_input_status` + `input_cache::save_input_status`). +/// Call this only while holding the per-input lock from +/// `PlaylistProcessingContext::get_input_lock` (as done in `download_input`). +pub(crate) async fn invalidate_input_cache_status( + ctx: &PlaylistProcessingContext, + input: &ConfigInput, +) { + let storage_dir = { ctx.config.config.load().storage_dir.clone() }; + let storage_path = input_cache::resolve_input_storage_path(&storage_dir, &input.name).await; + let mut status = input_cache::load_input_status(&storage_path); + if !status.clusters.is_empty() { + status.clusters.clear(); + input_cache::save_input_status(&storage_path, &status); + } +} + +pub(crate) async fn load_cached_input_playlist( + ctx: &PlaylistProcessingContext, + input: &Arc, +) -> (PlaylistSource, Option) { + match load_input_playlist(&ctx.config, input, None).await { + Ok(pl_source) => (pl_source, None), + Err(err) => (MemoryPlaylistSource::default().into_source(), Some(err)), + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn download_input( + ctx: &PlaylistProcessingContext, + input: &Arc, + allow_staged_input: bool, +) -> (Vec, PlaylistSource, Option, bool) { + if input.staged.is_some() && !allow_staged_input { + return (vec![], MemoryPlaylistSource::default().into_source(), None, false); + } + + let staged_overlay = if input.staged.is_none() { + let sources = ctx.config.sources.load(); + sources.get_staged_input_for_provider(&input.name).cloned() + } else { + None + }; + + // Coordination Logic + let need_download = !ctx.is_input_downloaded(&input.name).await; + // Keep this lock for the whole critical section (download + persist/load + mark processed) + // so parallel sources sharing the same input cannot observe a half-written state. + let mut input_lock = if need_download { Some(ctx.get_input_lock(&input.name).await) } else { None }; + let mut mark_as_processed = false; + + let mut playlist_download_result = if need_download { + // Check again after lock + let already_processed = ctx.is_input_downloaded(&input.name).await; + + if already_processed { + // Use empty results, will load from disk below + PlaylistDownloadResult::new(vec![], vec![], true, false) + } else if ctx.pre_processed_inputs.as_ref().is_some_and(|s| s.contains(&input.name)) { + // Input was already processed in a prior session; skip download and load from disk. + // Mark only after load succeeds (or fails) to avoid exposing a half-ready state. + mark_as_processed = true; + PlaylistDownloadResult::new(vec![], vec![], true, false) + } else { + mark_as_processed = true; + playlist_download_from_input(&ctx.client, &ctx.config, &ctx.events, input, ctx.stalker_refresh_mode).await + } + } else { + PlaylistDownloadResult::new(vec![], vec![], true, false) + }; + + let mut preloaded_playlist: Option<(PlaylistSource, Option)> = None; + if playlist_download_result.was_cached { + let (cached_playlist, cached_error) = load_cached_input_playlist(ctx, input).await; + // Defensive fallback: if cache metadata says "valid" but persisted data is unreadable, + // retry once before forcing a refresh. + let must_force_refresh = cached_error.is_some(); + if must_force_refresh { + warn!("Input '{}' cache hit produced unreadable playlist; retrying cached load once", input.name); + let (retry_playlist, retry_error) = load_cached_input_playlist(ctx, input).await; + if retry_error.is_none() { + preloaded_playlist = Some((retry_playlist, None)); + } else { + if input_lock.is_none() { + input_lock = Some(ctx.get_input_lock(&input.name).await); + } + // Re-check immediately after locking to avoid duplicate refreshes when another worker + // repaired the cache between our earlier retry and lock acquisition. + let (locked_retry_playlist, locked_retry_error) = load_cached_input_playlist(ctx, input).await; + if locked_retry_error.is_none() { + warn!("Input '{}' cache became readable after lock re-check; skipping refresh", input.name); + preloaded_playlist = Some((locked_retry_playlist, None)); + } else { + warn!( + "Input '{}' cached playlist remained unreadable after retry and lock re-check; invalidating cache and forcing refresh", + input.name + ); + invalidate_input_cache_status(ctx, input).await; + playlist_download_result = playlist_download_from_input( + &ctx.client, + &ctx.config, + &ctx.events, + input, + ctx.stalker_refresh_mode, + ) + .await; + } + } + } else { + preloaded_playlist = Some((cached_playlist, None)); + } + } + if playlist_download_result.partial { + ctx.partial_refresh.store(true, std::sync::atomic::Ordering::Release); + ctx.events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: input.name.to_string(), + message: stalker_checkpoint_message(&input.name), + })); + } + let apply_staged_overlay = should_apply_staged_overlay(&playlist_download_result); + + let (mut playlist, mut error) = if let Some(preloaded) = preloaded_playlist { + preloaded + } else if playlist_download_result.was_cached || playlist_download_result.persisted { + match load_input_playlist(&ctx.config, input, None).await { + Ok(pl_source) => (pl_source, None), + Err(e) => (MemoryPlaylistSource::default().into_source(), Some(e)), + } + } else { + debug!("Persisting input '{}' playlist", input.name); + let (pl, err) = persist_input_playlist(&ctx.config, input, playlist_download_result.downloaded_playlist).await; + (MemoryPlaylistSource::new(pl).into_source(), err) + }; + + playlist = filter_skipped_clusters_from_source(playlist, input); + + if let Some(staged_input) = staged_overlay.filter(|_| apply_staged_overlay) { + let clusters = staged_input.staged.as_ref().map_or_else(ClusterFlags::all, |staged| staged.clusters); + let (mut staged_download_err, mut staged_playlist, staged_error, staged_partial) = + Box::pin(download_input(ctx, &staged_input, true)).await; + playlist_download_result.partial |= staged_partial; + playlist_download_result.download_err.append(&mut staged_download_err); + if let Some(staged_error) = staged_error { + playlist_download_result.download_err.push(staged_error); + } else { + let provider_groups = playlist.take_groups(); + let staged_groups = staged_playlist.take_groups(); + let merged_groups = apply_staged_overlay_groups(&input.name, clusters, provider_groups, staged_groups); + let (merged_playlist, persist_error) = persist_input_playlist(&ctx.config, input, merged_groups).await; + playlist = MemoryPlaylistSource::new(merged_playlist).into_source(); + if error.is_none() { + error = persist_error; + } else if let Some(persist_error) = persist_error { + playlist_download_result.download_err.push(persist_error); + } + } + } + + if mark_as_processed && !playlist_download_result.partial && error.is_none() && !playlist.is_empty() { + // Mark after persist/load so other workers only see this input as ready when data is usable. + ctx.mark_input_downloaded(input.name.clone()).await; + } + + // Explicitly release per-input lock after load/persist/mark steps are completed. + drop(input_lock); + + (playlist_download_result.download_err, playlist, error, playlist_download_result.partial) +} + +pub(crate) fn create_broadcast_callback(events: &E) -> StepMeasureCallback { + let events = events.clone(); + Box::new(move |context: &str, msg: &str| { + events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: context.to_owned(), + message: msg.to_owned(), + })); + }) +} + +pub(crate) fn create_input_stat( + group_count: usize, + channel_count: usize, + error_count: usize, + input_type: InputType, + input_name: &str, + secs_took: u64, +) -> InputStats { + InputStats { + name: input_name.to_string(), + input_type, + error_count, + raw_stats: PlaylistStats { group_count, channel_count }, + processed_stats: PlaylistStats { group_count: 0, channel_count: 0 }, + secs_took, + } +} + +pub struct PlaylistProcessingContext { + pub client: reqwest::Client, + pub config: Arc, + pub user_targets: Arc, + pub events: E, + pub playlist_state: Option>, + /// Reverse-proxy header suppression, carried from the composition root. + /// + /// Nothing in the pipeline reads this today. It became visible when + /// `load_input_playlist` stopped taking the whole context, and it is left in + /// place rather than deleted because the plumbing exists in the API layer + /// and in `exec_processing`'s signature: a configured value that is accepted + /// and ignored is a behaviour question, not a refactoring one. + #[allow(dead_code)] + pub disabled_headers: Option, + + // Coordination + pub processed_inputs: Arc>>>, + #[allow(clippy::type_complexity)] + pub input_locks: Arc, Weak>>>>, + + // New field for STRM probes & background updates + pub provider_manager: Option>, + pub metadata_manager: Option>, + pub pre_processed_inputs: Option>>>, + pub stalker_refresh_mode: StalkerRefreshMode, + pub partial_refresh: Arc, +} + +// Written out rather than derived: `#[derive(Clone)]` would demand `M: Clone`, +// but the sink is held behind an `Arc` and is cloneable whatever `M` is. +impl Clone for PlaylistProcessingContext { + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + config: Arc::clone(&self.config), + user_targets: Arc::clone(&self.user_targets), + events: self.events.clone(), + playlist_state: self.playlist_state.clone(), + disabled_headers: self.disabled_headers.clone(), + processed_inputs: Arc::clone(&self.processed_inputs), + input_locks: Arc::clone(&self.input_locks), + provider_manager: self.provider_manager.clone(), + metadata_manager: self.metadata_manager.clone(), + pre_processed_inputs: self.pre_processed_inputs.clone(), + stalker_refresh_mode: self.stalker_refresh_mode, + partial_refresh: Arc::clone(&self.partial_refresh), + } + } +} + +impl PlaylistProcessingContext { + pub async fn is_input_downloaded(&self, input_name: &str) -> bool { + let processed = self.processed_inputs.lock().await; + processed.contains(input_name) + } + pub async fn mark_input_downloaded(&self, input_name: Arc) -> bool { + let mut processed = self.processed_inputs.lock().await; + processed.insert(input_name) + } + + pub async fn get_input_lock(&self, input_name: &Arc) -> OwnedRwLockWriteGuard<()> { + let mut locks = self.input_locks.lock().await; + // Try to upgrade the existing weak reference + let lock = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| { + let new_lock = Arc::new(RwLock::new(())); + locks.insert(input_name.clone(), Arc::downgrade(&new_lock)); + new_lock + }); + + // Clean up stale references periodically + locks.retain(|_, weak| weak.strong_count() > 0); + + drop(locks); // Release mutex before awaiting write lock + lock.write_owned().await + } +} + +pub(crate) async fn process_sources( + processing_ctx: &PlaylistProcessingContext, +) -> (Vec, Vec) { + let mut async_tasks = JoinSet::new(); + let sources = processing_ctx.config.sources.load(); + let process_parallel = processing_ctx.config.config.load().process_parallel; + if process_parallel && log_enabled!(Level::Debug) { + debug!("Parallel processing enabled"); + } + + let mut source_results = Vec::new(); + let mut errors = Vec::new(); + let mut processed_any = false; + + for (index, source) in sources.sources.iter().enumerate() { + if !source.should_process_for_user_targets(&processing_ctx.user_targets) { + continue; + } + + // We're using the file lock this way on purpose + let source_lock_path = PathBuf::from(concat_string!("source_", &index.to_string())); + let Ok(update_lock) = processing_ctx.config.file_locks.try_write_lock(&source_lock_path).await else { + warn!( + "The update operation for the source at index {index} was skipped because an update is already in progress." + ); + continue; + }; + + let ctx = Arc::new(processing_ctx.clone()); + + processed_any = true; + if process_parallel { + async_tasks.spawn(async move { + let _update_lock = update_lock; + (index, process_source(index, ctx).await) + }); + } else { + source_results.push((index, process_source(index, ctx).await)); + drop(update_lock); + } + } + if !processed_any { + warn!( + "No sources were processed for the given targets. Check that:\n\ + - Sources have enabled targets matching your target selection\n\ + - CLI -t filter or schedule.targets are correct\n\ + - No playlist lock is blocking updates" + ); + } + while let Some(result) = async_tasks.join_next().await { + match result { + Ok(result) => source_results.push(result), + Err(err) => { + error!("Playlist processing task failed: {err:?}"); + errors + .push(TuliproxError::RepositoryPlaylist(format!("Playlist source processing task failed: {err}"))); + } + } + } + + source_results.sort_by_key(|(index, _)| *index); + let mut stats = Vec::with_capacity(source_results.len()); + for (_, (input_stats, target_stats, mut source_errors)) in source_results { + errors.append(&mut source_errors); + if let Some(source_stats) = SourceStats::try_new(input_stats, target_stats) { + stats.push(source_stats); + } + } + (stats, errors) +} diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs new file mode 100644 index 000000000..639d3cf30 --- /dev/null +++ b/backend/processing/src/processor/playlist/mod.rs @@ -0,0 +1,401 @@ +use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider}; +use crate::{ + fetched_playlist::FetchedPlaylist, + input_cache, + input_cache::ClusterState, + metadata_sink::{MetadataUpdateSink, NoopMetadataSink}, + parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator, TVGuide}, + playlist_watch::{process_group_watch, process_target_groups_watch}, + processor::{ + epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups}, + sort::sort_playlist, + trakt::process_trakt_categories_for_target, + xtream_series::playlist_resolve_series, + xtream_vod::playlist_resolve_vod, + StalkerRefreshMode, + }, +}; +use futures::{FutureExt, StreamExt}; +use indexmap::IndexMap; +use log::{debug, error, info, log_enabled, warn, Level}; +use path_clean::PathClean; +use shared::{ + concat_string, + defaults::{default_as_default, default_probe_delay_secs, default_probe_live_interval}, + error::{get_errors_notify_message, TuliproxError}, + foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}, + model::{ + ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet, InputStats, + InputType, MappingStage, PipelineStats, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, + PlaylistUpdateProgressEvent, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, + TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster, + }, + utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable}, +}; +use std::{ + collections::{HashMap, HashSet}, + future::Future, + path::PathBuf, + sync::{Arc, Weak}, + time::{Duration, Instant}, +}; +use tokio::{ + sync::{watch, Mutex, OwnedRwLockWriteGuard, RwLock}, + task::JoinSet, +}; +use tuliprox_core::{ + model::{ + is_valid, retain_filtered_playlist, AppConfig, CompiledMapping, ConfigFavourites, ConfigInput, + ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg, FilterOutcome, MappingProgram, + ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig, TransformStage, UpdateGuard, + UpdateTask, + }, + utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback}, +}; +use tuliprox_iptv::{ + epg::{CountingEpgSink, EpgFetchRequest, EpgProvider}, + error::ProviderErrorKind, + provider::{ + BatchContainerProvider, M3uProvider, PlaylistFetch, PlaylistFetchRequest, PlaylistProvider, + UnsupportedProvider, XtreamProvider, + }, + xtream, +}; +use tuliprox_repository::{ + load_input_playlist, persist_input_playlist, persist_playlist, CategoryKey, MemoryPlaylistSource, PlaylistSource, + PlaylistStorageState, +}; +use tuliprox_session::ActiveProviderManager; + +const PLAYLIST_UPDATE_MAX_DURATION_SECS: u64 = 3600; +const MAX_CONCURRENT_TARGET_FINALIZERS: usize = 2; + +mod ingest; +mod target; +mod transform; + +pub use self::{ingest::*, target::*, transform::*}; + +/// Work the composition root runs once the playlist lock is held, before the +/// update proper starts. +/// +/// This was an `Option>` used for exactly one call. Passing the +/// call instead of the state keeps `processing` from naming the server state. +/// +/// It was then an +/// `Arc Pin + Send>> + Send + Sync>`: +/// two layers of erasure and a heap allocation for a future that is awaited +/// exactly once per update, and every call site had to spell out both +/// coercions. As a trait it is one type parameter, monomorphised, with the +/// future returned by value. +pub trait UpdateBootstrap: Send + Sync + 'static { + fn run(&self) -> impl Future + Send; +} + +impl UpdateBootstrap for F +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + fn run(&self) -> impl Future + Send { self() } +} + +/// The bootstrap type parameter of a run that has no bootstrap. +/// +/// A function pointer rather than a unit struct: it satisfies the blanket +/// `Fn` impl above, so no second impl - and no coherence problem - is needed. +/// A value of this type is never constructed; the field is always `None`. +pub type NoBootstrap = fn() -> std::future::Ready<()>; + +/// Everything one playlist update run needs. +/// +/// `exec_processing` took twelve positional arguments, seven of them +/// `Option<_>`, so a call site was a wall of `None`s and `Some(..)`s in which +/// the reader had to count commas to work out which knob was being set - and +/// the compiler could not catch two same-typed arguments swapped. +/// +/// Four of the twelve are always present, so they are constructor arguments. +/// The rest are optional in fact as well as in type, and a call site names the +/// ones it actually sets. +pub struct ProcessingRun< + E: EventSink + Clone + 'static, + B: UpdateBootstrap = NoBootstrap, + M: MetadataUpdateSink = NoopMetadataSink, +> { + client: reqwest::Client, + app_config: Arc, + targets: Arc, + events: E, + bootstrap: Option, + playlist_state: Option>, + update_guard: Option, + disabled_headers: Option, + provider_manager: Option>, + metadata_manager: Option>, + pre_processed_inputs: Option>>, + acquired_permit: Option, +} + +impl ProcessingRun { + pub fn new(client: reqwest::Client, app_config: Arc, targets: Arc, events: E) -> Self { + Self { + client, + app_config, + targets, + events, + bootstrap: None, + playlist_state: None, + update_guard: None, + disabled_headers: None, + provider_manager: None, + metadata_manager: None, + pre_processed_inputs: None, + acquired_permit: None, + } + } +} + +impl ProcessingRun { + /// Work the composition root runs once the lock is held, before the update + /// proper starts. + /// + /// Changes the run's bootstrap type, so it rebuilds rather than mutates. + #[must_use] + pub fn with_bootstrap(self, bootstrap: B2) -> ProcessingRun { + ProcessingRun { + client: self.client, + app_config: self.app_config, + targets: self.targets, + events: self.events, + bootstrap: Some(bootstrap), + playlist_state: self.playlist_state, + update_guard: self.update_guard, + disabled_headers: self.disabled_headers, + provider_manager: self.provider_manager, + metadata_manager: self.metadata_manager, + pre_processed_inputs: self.pre_processed_inputs, + acquired_permit: self.acquired_permit, + } + } + + #[must_use] + pub fn with_playlist_state(mut self, state: impl Into>>) -> Self { + self.playlist_state = state.into(); + self + } + + /// The lock this run acquires. Ignored when an already-acquired permit is + /// supplied via [`Self::with_acquired_permit`]. + #[must_use] + pub fn with_update_guard(mut self, guard: impl Into>) -> Self { + self.update_guard = guard.into(); + self + } + + #[must_use] + pub fn with_disabled_headers(mut self, headers: impl Into>) -> Self { + self.disabled_headers = headers.into(); + self + } + + #[must_use] + pub fn with_provider_manager(mut self, manager: impl Into>>) -> Self { + self.provider_manager = manager.into(); + self + } + + /// The background metadata worker. + /// + /// Changes the run's sink type, so it rebuilds rather than mutates. + #[must_use] + pub fn with_metadata_manager(self, manager: Arc) -> ProcessingRun { + ProcessingRun { + client: self.client, + app_config: self.app_config, + targets: self.targets, + events: self.events, + bootstrap: self.bootstrap, + playlist_state: self.playlist_state, + update_guard: self.update_guard, + disabled_headers: self.disabled_headers, + provider_manager: self.provider_manager, + metadata_manager: Some(manager), + pre_processed_inputs: self.pre_processed_inputs, + acquired_permit: self.acquired_permit, + } + } + + // Always built with the default hasher here; generalising would buy nothing. + #[allow(clippy::implicit_hasher)] + #[must_use] + pub fn with_pre_processed_inputs(mut self, inputs: impl Into>>>) -> Self { + self.pre_processed_inputs = inputs.into(); + self + } + + /// A playlist lock the caller already holds. Takes precedence over + /// [`Self::with_update_guard`], which would otherwise acquire a second one. + #[must_use] + pub fn with_acquired_permit(mut self, permit: impl Into>) -> Self { + self.acquired_permit = permit.into(); + self + } +} + +#[allow(clippy::too_many_lines)] +pub async fn exec_processing( + run: ProcessingRun, +) { + let ProcessingRun { + client, + app_config, + targets, + events, + bootstrap, + playlist_state, + update_guard, + disabled_headers, + provider_manager, + metadata_manager, + pre_processed_inputs, + acquired_permit, + } = run; + + let max_update_duration = Duration::from_secs(PLAYLIST_UPDATE_MAX_DURATION_SECS); + let playlist_guard = if let Some(permit) = acquired_permit { + Some(permit) + } else if let Some(guard) = &update_guard { + if let Some(permit) = guard.acquire_playlist_lock().await { + Some(permit) + } else { + warn!("Playlist update lock is closed; update skipped."); + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); + return; + } + } else { + None + }; + + if playlist_guard.is_some() { + if let Some(bootstrap) = bootstrap.as_ref() { + if tokio::time::timeout(max_update_duration, bootstrap.run()).await.is_err() { + error!( + "Playlist update bootstrap timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", + ); + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); + return; + } + } + } + + // Pause background metadata/probe tasks for the full update lifecycle. + let _background_pause_guard = if let Some(manager) = metadata_manager.as_ref() { + Some(manager.acquire_update_pause_guard().await) + } else { + None + }; + + info!("🌷 Update process started."); + + log_memory_snapshot("exec_processing start"); + + // Initialize Context + let ctx = PlaylistProcessingContext { + client, + config: app_config.clone(), + user_targets: targets.clone(), + events: events.clone(), + playlist_state: playlist_state.clone(), + processed_inputs: Arc::new(Mutex::new(HashSet::new())), + input_locks: Arc::new(Mutex::new(HashMap::new())), + disabled_headers, + provider_manager, + metadata_manager, + pre_processed_inputs: pre_processed_inputs.map(Arc::new), + stalker_refresh_mode: if app_config.config.load().process_parallel { + StalkerRefreshMode::Parallel + } else if update_guard.is_some() { + StalkerRefreshMode::ServerSlice + } else { + StalkerRefreshMode::Complete + }, + partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let start_time = Instant::now(); + let process_result = + tokio::time::timeout(max_update_duration, std::panic::AssertUnwindSafe(process_sources(&ctx)).catch_unwind()) + .await; + let (stats, errors) = match process_result { + Ok(Ok((stats, errors))) => (stats, errors), + Ok(Err(_)) => { + error!("Playlist processing panicked"); + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); + return; + } + Err(_) => { + error!( + "Playlist processing timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", + ); + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); + return; + } + }; + log_memory_snapshot("exec_processing after_process_sources"); + + // Keep the update lock only for the critical processing section. + drop(playlist_guard); + debug!("Released playlist update lock; dispatching notifications and events"); + + // log errors + for err in &errors { + error!("{}", err.message()); + } + + if !stats.is_empty() { + if let Ok(stats_msg) = serde_json::to_string(&stats) { + info!("stats: {stats_msg}"); + } + } + + // One event for the whole run, carrying both the outcome and what it + // did. These used to be two independent messages - the statistics went + // straight to the notification layer, the outcome went to the bus - and + // because both resolve to `playlist.update.completed`, a successful + // refresh notified twice. Subscribers now get one event with everything, + // and the bridge renders the single message from it. + let error = get_errors_notify_message!(errors, 255); + let outcome = if error.is_some() { + shared::model::PlaylistUpdateState::Failure + } else if ctx.partial_refresh.load(std::sync::atomic::Ordering::Acquire) { + shared::model::PlaylistUpdateState::Partial + } else { + shared::model::PlaylistUpdateState::Success + }; + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary { state: outcome, stats, error })); + + let elapsed = start_time.elapsed().as_secs(); + let update_finished_message = format!("🌷 Update process finished! Took {elapsed} secs."); + + events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: "Playlist Update".to_string(), + message: update_finished_message.clone(), + })); + log_memory_snapshot("exec_processing before_interner_gc"); + debug!("StringInterner GC removed {} strings", interner_gc()); + log_memory_snapshot("exec_processing after_interner_gc"); + //trim_allocator_after_update(); + + info!("{update_finished_message}"); +} + +#[cfg(test)] +mod tests; diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs new file mode 100644 index 000000000..6b6373174 --- /dev/null +++ b/backend/processing/src/processor/playlist/target.rs @@ -0,0 +1,858 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +pub(crate) fn join_arc_strs(values: &[Arc], separator: &str) -> String { + let mut result = String::new(); + for value in values { + if !result.is_empty() { + result.push_str(separator); + } + result.push_str(value.as_ref()); + } + result +} + +pub(crate) fn target_waiting_message(target: &str, input: &str) -> String { + format!("Target '{target}' is waiting for input '{input}'") +} + +pub(crate) fn target_mutated_resources( + config: &tuliprox_core::model::Config, + target: &ConfigTarget, +) -> HashSet { + let mut resources = HashSet::new(); + if let Some(path) = tuliprox_repository::get_target_storage_path(config, &target.name) { + resources.insert(path.clean()); + } + for output in &target.output { + match output { + tuliprox_core::model::TargetOutput::M3u(output) => { + if let Some(path) = tuliprox_core::utils::get_file_path( + &config.storage_dir, + output.filename.as_deref().map(PathBuf::from), + ) { + resources.insert(path.clean()); + } + } + tuliprox_core::model::TargetOutput::Strm(output) => { + if let Some(path) = + tuliprox_core::utils::get_file_path(&config.storage_dir, Some(PathBuf::from(&output.directory))) + { + resources.insert(path.clean()); + } + } + tuliprox_core::model::TargetOutput::Xtream(_) | tuliprox_core::model::TargetOutput::HdHomeRun(_) => {} + } + } + resources +} + +pub(crate) fn stalker_checkpoint_message(input: &str) -> String { + format!("Input '{input}': Stalker refresh checkpoint saved; active snapshot remains in service") +} + +pub(crate) fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bool { + (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) +} + +pub(crate) struct TargetJobResult { + pub(crate) index: usize, + pub(crate) name: String, + pub(crate) result: Result<(), Vec>, + pub(crate) errors: Vec, + pub(crate) processing: PipelineStats, +} + +pub(crate) fn collect_target_task_result( + result: Result, + results: &mut Vec, + errors: &mut Vec, +) { + match result { + Ok(result) => results.push(result), + Err(err) => errors.push(TuliproxError::RepositoryPlaylist(format!("Target finalization task failed: {err}"))), + } +} + +pub(crate) async fn wait_for_target_finalizer_slot( + tasks: &mut JoinSet, + results: &mut Vec, + errors: &mut Vec, +) { + if tasks.len() >= MAX_CONCURRENT_TARGET_FINALIZERS { + if let Some(result) = tasks.join_next().await { + collect_target_task_result(result, results, errors); + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn process_targets( + ctx: &Arc>, + playlists: &mut [FetchedPlaylist<'_>], + targets: &[&Arc], + input_stats: &mut HashMap, InputStats>, + errors: &mut Vec, + process_parallel: bool, +) -> Vec { + if !process_parallel { + let mut target_stats = Vec::with_capacity(targets.len()); + for (index, target) in targets.iter().enumerate() { + let consume_input_source = index + 1 == targets.len(); + let result = + prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, consume_input_source).await; + match result { + Ok(prepared) => { + let processing = prepared.processing.clone(); + let (result, mut finalization_errors) = finalize_prepared_target(Arc::clone(ctx), prepared).await; + errors.append(&mut finalization_errors); + match result { + Ok(()) => target_stats.push(TargetStats::success_with_processing(&target.name, processing)), + Err(mut target_errors) => { + target_stats.push(TargetStats::failure_with_processing(&target.name, processing)); + errors.append(&mut target_errors); + } + } + } + Err(mut target_errors) => { + target_stats.push(TargetStats::failure(&target.name)); + errors.append(&mut target_errors); + } + } + } + return target_stats; + } + + let resources = { + let config = ctx.config.config.load(); + targets.iter().map(|target| target_mutated_resources(&config, target)).collect::>() + }; + let mut completion_receivers: Vec> = Vec::with_capacity(targets.len()); + let mut tasks = JoinSet::new(); + let mut results = Vec::with_capacity(targets.len()); + + for (index, target) in targets.iter().enumerate() { + wait_for_target_finalizer_slot(&mut tasks, &mut results, errors).await; + let predecessors = resources[..index] + .iter() + .zip(&completion_receivers) + .filter(|(earlier, _)| !earlier.is_disjoint(&resources[index])) + .map(|(_, receiver)| receiver.clone()) + .collect::>(); + let (completion, receiver) = watch::channel(false); + completion_receivers.push(receiver); + + match prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, false).await { + Ok(prepared) => { + let processing = prepared.processing.clone(); + let task_ctx = Arc::clone(ctx); + let target_name = target.name.clone(); + tasks.spawn(async move { + for mut predecessor in predecessors { + if !*predecessor.borrow() { + let _ = predecessor.changed().await; + } + } + let finalized = + std::panic::AssertUnwindSafe(finalize_prepared_target(task_ctx, prepared)).catch_unwind().await; + completion.send_replace(true); + match finalized { + Ok((result, errors)) => { + TargetJobResult { index, name: target_name, result, errors, processing } + } + Err(_) => TargetJobResult { + index, + name: target_name.clone(), + result: Err(vec![TuliproxError::RepositoryPlaylist(format!( + "Target '{target_name}' finalization panicked" + ))]), + errors: Vec::new(), + processing, + }, + } + }); + } + Err(target_errors) => { + completion.send_replace(true); + results.push(TargetJobResult { + index, + name: target.name.clone(), + result: Err(target_errors), + errors: Vec::new(), + processing: PipelineStats::default(), + }); + } + } + } + + while let Some(result) = tasks.join_next().await { + collect_target_task_result(result, &mut results, errors); + } + results.sort_by_key(|result| result.index); + + let mut target_stats = Vec::with_capacity(results.len()); + for mut target_result in results { + errors.append(&mut target_result.errors); + match target_result.result { + Ok(()) => { + target_stats.push(TargetStats::success_with_processing(&target_result.name, target_result.processing)); + } + Err(mut target_errors) => { + target_stats.push(TargetStats::failure_with_processing(&target_result.name, target_result.processing)); + errors.append(&mut target_errors); + } + } + } + target_stats +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FinalizationStage { + Merge, + Deduplicate, + Sort, + AssignChannelNumbers, + AssignCounters, +} + +pub(crate) const FINALIZATION_ORDER: [FinalizationStage; 5] = [ + FinalizationStage::Merge, + FinalizationStage::Deduplicate, + FinalizationStage::Sort, + FinalizationStage::AssignChannelNumbers, + FinalizationStage::AssignCounters, +]; + +pub(crate) fn apply_persist_filter(target: &ConfigTarget, groups: &mut Vec) { + let Some(filter) = target.filter.persist.as_ref() else { + return; + }; + let outcome = retain_filtered_playlist(groups, filter); + debug!("Target '{}' persist filter outcome: {outcome:?}", target.name); +} + +pub(crate) struct PreparedTarget { + pub(crate) target: ConfigTarget, + pub(crate) playlist: Vec, + pub(crate) epg: Vec, + pub(crate) processing: PipelineStats, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn prepare_playlist_for_target( + ctx: &PlaylistProcessingContext, + playlists: &mut [FetchedPlaylist<'_>], + target: &ConfigTarget, + stats: &mut HashMap, InputStats>, + errors: &mut Vec, + consume_input_source: bool, +) -> Result> { + debug_if_enabled!("Processing order is {}", &target.processing_order); + log_memory_snapshot(format!("target '{}' start", target.name).as_str()); + + let mut duplicates: HashSet = HashSet::new(); + let mut new_epg = vec![]; + let mut new_playlist: Vec = vec![]; + let mut aggregate_outcome = PipelineOutcome::default(); + + debug!("Executing processing pipes"); + let broadcast_step = create_broadcast_callback(&ctx.events); + + let pipe = get_processing_pipe(target); + let mut step = StepMeasure::new(&target.name, broadcast_step); + for provider_fpl in playlists.iter_mut() { + log_memory_snapshot( + format!("target '{}' input '{}' before_pipe", target.name, provider_fpl.input.name).as_str(), + ); + step.broadcast("Executing transformations on '{}' playlist", &target.name); + let (mut processed_fpl, input_outcome) = + execute_pipe(target, &pipe, provider_fpl, &mut duplicates, consume_input_source) + .map_err(|err| vec![err])?; + debug!("Target '{}' input '{}' pipeline outcome: {input_outcome:?}", target.name, provider_fpl.input.name); + aggregate_outcome.merge(input_outcome); + log_memory_snapshot( + format!("target '{}' input '{}' after_pipe", target.name, provider_fpl.input.name).as_str(), + ); + processed_fpl.sort_by_provider_ordinal(); + playlist_resolve(ctx, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; + log_memory_snapshot( + format!("target '{}' input '{}' after_vod_resolve", target.name, provider_fpl.input.name).as_str(), + ); + let clear_invalid_epg_ids = target.options.as_ref().is_some_and(ConfigTargetOptions::clear_invalid_epg_ids); + let input_epg_start = new_epg.len(); + process_playlist_epg(&mut processed_fpl, &mut new_epg, clear_invalid_epg_ids).await; + log_memory_snapshot( + format!("target '{}' input '{}' after_epg_apply", target.name, processed_fpl.input.name).as_str(), + ); + let deduplicate = target.execution_plan.pre_transform_identity_dedup; + if let Some(groups) = map_playlist_at_stage( + &mut processed_fpl.source, + target, + MappingStage::AfterEpg, + deduplicate.then_some(&mut duplicates), + ) { + processed_fpl.source = MemoryPlaylistSource::new(groups).into_source(); + } + if clear_invalid_epg_ids && processed_fpl.epg.is_some() { + clear_invalid_live_epg_ids(&mut processed_fpl, &new_epg[input_epg_start..]); + } + if let Some(stat) = stats.get_mut(&processed_fpl.input.name) { + stat.processed_stats.group_count = processed_fpl.get_group_count(); + stat.processed_stats.channel_count = processed_fpl.get_channel_count(); + } + new_playlist.extend(processed_fpl.source.take_groups()); + log_memory_snapshot( + format!("target '{}' input '{}' after_take_groups", target.name, processed_fpl.input.name).as_str(), + ); + tokio::task::yield_now().await; + } + step.tick("filter rename map + epg"); + log_memory_snapshot(format!("target '{}' after_filter_rename_map_epg", target.name).as_str()); + step.stop("Preparing playlist"); + Ok(PreparedTarget { + target: target.clone(), + playlist: new_playlist, + epg: new_epg, + processing: aggregate_outcome.to_stats(), + }) +} + +/// Spill each `Epg` source to a temp `BPlusTree` and merge them. Extracted +/// from `finalize_prepared_target` so it can be unit-tested without +/// constructing a full `PlaylistProcessingContext`. +/// +/// Returns `Ok(None)` if `sources` is empty (no EPG to merge), matching +/// the contract of `flatten_tvguide`. The temp directory lives inside +/// this function call — all temp files are removed by the +/// `DiskEpgSource` drop guards before this function returns. +pub(crate) fn spill_epg_to_disk(sources: Vec) -> Result, TuliproxError> { + let dir = + tempfile::tempdir().map_err(|e| TuliproxError::RepositoryXtream(format!("tempdir for EPG spill: {e}")))?; + let mut disk_sources = Vec::with_capacity(sources.len()); + for (source_order, guide) in sources.into_iter().enumerate() { + let mut acc = EpgMergeAccumulator::new(); + acc.set_attributes_if_preferred(guide.priority, source_order, guide.attributes); + for channel in guide.children { + acc.add_channel_with_programmes( + guide.priority, + source_order, + guide.logo_override, + std::sync::Arc::unwrap_or_clone(channel), + ); + } + let path = dir.path().join(format!("epg-src-{source_order}.db")); + let source_order_u32 = u32::try_from(source_order).unwrap_or(0); + let source = acc + .finish_into_disk(path, guide.priority, source_order_u32) + .map_err(|e| TuliproxError::RepositoryXtream(format!("EPG spill to disk failed: {e}")))?; + disk_sources.push(source); + } + if disk_sources.is_empty() { + Ok(None) + } else { + merge_epg_trees(disk_sources) + .map_err(|e| TuliproxError::RepositoryXtream(format!("EPG disk merge failed: {e}"))) + .map(|opt| opt.map(|(epg, _)| epg)) + } +} + +pub(crate) async fn finalize_prepared_target( + ctx: Arc>, + prepared: PreparedTarget, +) -> (Result<(), Vec>, Vec) { + let target = &prepared.target; + let mut new_playlist = prepared.playlist; + let mut new_epg = prepared.epg; + let mut errors = Vec::new(); + let broadcast_step = create_broadcast_callback(&ctx.events); + let mut step = StepMeasure::new(&target.name, broadcast_step); + if target.favourites.is_some() { + step.broadcast("Processing favourites for '{}' playlist", &target.name); + process_favourites(&mut new_playlist, target.favourites.as_deref()); + log_memory_snapshot(format!("target '{}' after_favourites", target.name).as_str()); + } + + if new_playlist.is_empty() { + step.stop(""); + info!("Playlist is empty: {}", target.name); + (Ok(()), errors) + } else { + // Process Trakt categories + if trakt_playlist(&ctx.client, target, &mut errors, &mut new_playlist).await { + step.tick("trakt categories"); + log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str()); + } + + let mut flat_new_playlist = flatten_groups(new_playlist); + step.tick("playlist merge"); + log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str()); + + for stage in FINALIZATION_ORDER.into_iter().skip(1) { + match stage { + FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"), + FinalizationStage::Deduplicate => { + if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() { + let removed = + crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist); + if removed > 0 { + info!("Deduplicated {removed} channels for target {}", target.name); + } + step.tick("playlist dedup"); + log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str()); + } + } + FinalizationStage::Sort => { + if sort_playlist(target, &mut flat_new_playlist) { + step.tick("playlist sort"); + log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str()); + } + } + FinalizationStage::AssignChannelNumbers => { + assign_channel_no_playlist(&mut flat_new_playlist); + step.tick("assigning channel numbers"); + log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str()); + } + FinalizationStage::AssignCounters => { + map_playlist_counter(target, &mut flat_new_playlist); + step.tick("assigning channel counter"); + log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str()); + } + } + } + + apply_persist_filter(target, &mut flat_new_playlist); + retain_epg_referenced_by_groups(&flat_new_playlist, &mut new_epg); + + if process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await { + step.tick("group watches"); + log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str()); + } + let merged_epg = if ctx.config.config.load().disk_based_processing { + // Per-source drain to disk, then multi-way merge. Errors are pushed + // to `errors` rather than `?` because the function returns + // `(Result, Vec)`, not `Result` directly. We must + // surface tempdir / write / merge failures — the user opted in to + // disk spilling, and silently falling back to the in-memory path + // can OOM on large feeds. When the spill itself fails we skip the + // persist step entirely: continuing with `merged_epg = None` would + // overwrite the existing on-disk EPG with nothing and discard the + // previously persisted artifact on a transient error. + match spill_epg_to_disk(new_epg) { + Ok(epg) => epg, + Err(err) => { + let result_error = TuliproxError::new(err.kind(), err.message()); + errors.push(err); + step.stop("EPG spill failed; skipping persist to preserve existing EPG"); + log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str()); + return (Err(vec![result_error]), errors); + } + } + } else { + flatten_tvguide(new_epg) + }; + let result = persist_playlist( + &ctx.config, + &mut flat_new_playlist, + merged_epg.as_ref(), + target, + ctx.playlist_state.as_ref(), + ) + .await; + step.stop("Persisting playlists"); + log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str()); + (result, errors) + } +} + +pub(crate) async fn playlist_resolve( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + errors: &mut Vec, + pipe: &ProcessingPipe, + provider_fpl: &mut FetchedPlaylist<'_>, + processed_fpl: &mut FetchedPlaylist<'_>, +) { + playlist_resolve_series(ctx, target, errors, pipe, provider_fpl, processed_fpl).await; + playlist_resolve_vod(ctx, target, errors, provider_fpl, processed_fpl).await; + playlist_probe(ctx, target, processed_fpl).await; +} + +pub(crate) fn is_probe_supported_item_type(item_type: PlaylistItemType) -> bool { + matches!( + item_type, + PlaylistItemType::Live // we skip other live streams because hls and dash have multiple resolutions + | PlaylistItemType::Video + | PlaylistItemType::LocalVideo + | PlaylistItemType::Series + | PlaylistItemType::LocalSeries + ) +} + +pub(crate) fn has_probe_details(item: &PlaylistItem) -> bool { + match item.header.additional_properties.as_ref() { + Some(StreamProperties::Video(v)) => v.details.as_ref().is_some_and(|d| d.video.is_some() && d.audio.is_some()), + Some(StreamProperties::Live(l)) => l.video.is_some() && l.audio.is_some() && l.bitrate > 0, + Some(StreamProperties::Episode(e)) => e.video.is_some() && e.audio.is_some(), + Some(StreamProperties::Series(_)) | None => false, + } +} + +pub(crate) fn get_live_probe_interval_settings( + target: &ConfigTarget, + input_type: InputType, + input_options: Option<&ConfigInputOptions>, +) -> Option<(u16, u64)> { + if !(input_type.is_xtream() || input_type.is_m3u() || input_type.is_stalker()) { + return None; + } + target.get_xtream_output().map(|_| { + let (probe_delay, input_probe_live_interval_hours) = input_options + .map_or((default_probe_delay_secs(), default_probe_live_interval()), |options| { + (options.probe_delay, options.probe_live_interval_hours) + }); + (probe_delay, u64::from(input_probe_live_interval_hours) * 3600) + }) +} + +pub(crate) fn needs_live_probe(item: &PlaylistItem, cutoff_ts: i64) -> bool { + match item.header.additional_properties.as_ref() { + Some(StreamProperties::Live(props)) => { + props.bitrate == 0 || props.last_probed_timestamp.is_none_or(|last_ts| last_ts < cutoff_ts) + } + _ => true, + } +} + +pub(crate) fn provider_id_from_item(item: &PlaylistItem) -> Option { + if let Ok(id) = item.header.id.parse::() { + if id == 0 { + return None; + } + return Some(ProviderIdType::Id(id)); + } + + let raw = item.header.id.trim(); + if raw.is_empty() { + None + } else { + Some(ProviderIdType::from(raw)) + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn playlist_probe( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + fpl: &mut FetchedPlaylist<'_>, +) { + let Some(mgr) = ctx.metadata_manager.as_ref() else { + return; + }; + let Some(opts) = fpl.input.options.as_ref() else { + return; + }; + let probe_live_enabled = opts.has_flag(ConfigInputFlags::ProbeLive); + let probe_vod_enabled = opts.has_flag(ConfigInputFlags::ProbeVod); + let probe_series_enabled = opts.has_flag(ConfigInputFlags::ProbeSeries); + + if !(probe_live_enabled || probe_vod_enabled || probe_series_enabled) { + return; + } + if !ctx.config.is_ffprobe_enabled().await { + return; + } + + let input_name = fpl.input.name.clone(); + // The first `should_skip_enqueue` for an input needs its persisted enqueue + // state on disk; inputs where no item reaches that check must not pay for + // the load, so it happens on first use rather than here. + let mut enqueue_state_prepared = false; + let effective_input_type = fpl.input.get_download_input_type(); + let xtream_probe_handled = effective_input_type.is_xtream() && target.get_xtream_output().is_some(); + let live_probe_settings = if probe_live_enabled { + get_live_probe_interval_settings(target, effective_input_type, Some(opts)).map(|(delay, interval_secs)| { + let interval_signed = i64::try_from(interval_secs).unwrap_or(i64::MAX); + let cutoff_ts = chrono::Utc::now().timestamp().saturating_sub(interval_signed); + (delay, interval_secs, cutoff_ts) + }) + } else { + None + }; + + let mut queued_probe_keys: HashSet<(Arc, String)> = HashSet::new(); + let mut queued_live_keys: HashSet = HashSet::new(); + let mut queued_live_count = 0usize; + let mut queued_stream_count = 0usize; + + let probe_filter = fpl.input.options.as_ref().and_then(|o| o.probe_filter.as_ref()); + + for item in fpl.items() { + if !is_probe_supported_item_type(item.header.item_type) { + continue; + } + match item.header.item_type { + PlaylistItemType::Live => { + if !probe_live_enabled { + continue; + } + } + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + if !probe_vod_enabled { + continue; + } + } + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + if !probe_series_enabled { + continue; + } + } + _ => continue, + } + + // If input has a probe filter and this item doesn't match, skip probing + if let Some(p_filter) = probe_filter { + let provider = ValueProvider { pli: &item, match_as_ascii: false }; + if !p_filter.filter(&provider) { + continue; + } + } + + match item.header.item_type { + PlaylistItemType::Live => { + if let Some((probe_delay, interval_secs, cutoff_ts)) = live_probe_settings { + if needs_live_probe(&item, cutoff_ts) { + if let Some(provider_id) = provider_id_from_item(&item) { + if queued_live_keys.insert(provider_id.clone()) { + let task = UpdateTask::ProbeLive { + id: provider_id.clone(), + reason: ResolveReason::Probe.into(), + delay: probe_delay, + interval: interval_secs, + }; + if !enqueue_state_prepared { + mgr.prepare_enqueue_state(input_name.clone()).await; + enqueue_state_prepared = true; + } + if mgr.should_skip_enqueue(&input_name, &task) { + continue; + } + if log_enabled!(Level::Debug) { + let last_probed = match item.header.additional_properties.as_ref() { + Some(StreamProperties::Live(props)) => props.last_probed_timestamp, + _ => None, + }; + debug!( + "[Task] Creating ProbeLive task for input {}: id={}, last_probed_ts={:?}, cutoff_ts={}, interval={}s, title=\"{}\"", + input_name, + provider_id, + last_probed, + cutoff_ts, + interval_secs, + item.header.title + ); + } + Arc::clone(mgr).queue_task_background(input_name.clone(), task); + queued_live_count += 1; + } + } + } + continue; + } + // If live probes are enabled but no live-specific settings are available, fall through to the + // generic probe path to keep behaviour consistent with non-xtream outputs. + } + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + // Xtream outputs handle VOD probe as part of the resolve pipeline (after resolve). + if xtream_probe_handled { + continue; + } + } + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + // Xtream outputs handle Series probe as part of the resolve pipeline (after resolve). + if xtream_probe_handled { + continue; + } + } + _ => continue, + } + + if has_probe_details(&item) { + continue; + } + + // For M3U, ID is a provider id; for Library, ID is UUID. + let unique_id = if effective_input_type == InputType::Library { + item.header.uuid.to_valid_uuid() + } else { + item.header.id.to_string() + }; + let probe_scope = + if item.header.input_name.is_empty() { input_name.clone() } else { item.header.input_name.clone() }; + + if !queued_probe_keys.insert((probe_scope.clone(), unique_id.clone())) { + continue; + } + + let task = UpdateTask::ProbeStream { + probe_scope: probe_scope.clone(), + unique_id: unique_id.clone(), + url: item.header.url.to_string(), + item_type: item.header.item_type, + reason: ResolveReason::MissingDetails.into(), + delay: opts.probe_delay, + }; + if !enqueue_state_prepared { + mgr.prepare_enqueue_state(input_name.clone()).await; + enqueue_state_prepared = true; + } + if mgr.should_skip_enqueue(&input_name, &task) { + continue; + } + debug!( + "[Task] Creating ProbeStream task for input {}: scope={}, unique_id={}, item_type={:?}, title=\"{}\"", + input_name, probe_scope, unique_id, item.header.item_type, item.header.title + ); + Arc::clone(mgr).queue_task_background(input_name.clone(), task); + queued_stream_count += 1; + } + + if queued_live_count > 0 || queued_stream_count > 0 { + info!( + "Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})" + ); + } +} + +pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Option<&[ConfigFavourites]>) { + if let Some(favourites) = favourites_cfg { + let mut fav_groups: IndexMap> = IndexMap::new(); + for pg in playlist.iter() { + for pli in &pg.channels { + // series episodes can't be included in favourites + if pli.header.item_type == PlaylistItemType::Series + || pli.header.item_type == PlaylistItemType::LocalSeries + { + continue; + } + for fav in favourites { + if pli.header.xtream_cluster == fav.cluster && is_valid(pli, &fav.filter, fav.match_as_ascii) { + let mut channel = pli.clone(); + channel.header.group.clone_from(&fav.group); + // Update UUID to be an alias of the original + channel.header.uuid = create_alias_uuid(&pli.header.uuid, &fav.group); + fav_groups.entry((fav.cluster, fav.group.clone())).or_default().push(channel); + } + } + } + } + + for (fav_group, channels) in fav_groups { + if !channels.is_empty() { + let (xtream_cluster, group_name) = fav_group; + playlist.push(PlaylistGroup { id: 0, title: group_name, channels, xtream_cluster }); + } + } + } +} + +pub(crate) async fn trakt_playlist( + client: &reqwest::Client, + target: &ConfigTarget, + errors: &mut Vec, + playlist: &mut Vec, +) -> bool { + match process_trakt_categories_for_target(client, playlist, target).await { + Ok(Some(trakt_categories)) => { + if !trakt_categories.is_empty() { + info!("Adding {} Trakt categories to playlist", trakt_categories.len()); + playlist.extend(trakt_categories); + } + } + Ok(None) => { + return false; + } + Err(trakt_errors) => { + warn!("Trakt processing failed with {} errors", trakt_errors.len()); + errors.extend(trakt_errors); + } + } + true +} + +pub(crate) async fn process_watch( + app_config: &Arc, + events: &E, + target: &ConfigTarget, + new_playlist: &[PlaylistGroup], +) -> bool { + let Some(watches) = &target.watch else { + return false; + }; + + // Configured, but every pattern failed to compile. Silently doing + // nothing here is what made a typo in `watch` indistinguishable from a + // playlist that never changes. + if watches.is_empty() { + error!("target '{}' configured watch patterns but none of them compiled", target.name); + events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + target.name.clone(), + WatchDisabledReason::InvalidPatterns, + ))); + return false; + } + + if default_as_default().eq_ignore_ascii_case(&target.name) { + error!("can't watch a target with no unique name"); + events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + target.name.clone(), + WatchDisabledReason::UnnamedTarget, + ))); + return false; + } + + // Before the per-group fan-out: this is about which groups exist, not + // what is inside the ones the patterns name, so it must see every group + // rather than only the watched ones. + process_target_groups_watch(app_config, events, &target.name, new_playlist).await; + + let mut matched = vec![false; watches.len()]; + let mut watched_groups = Vec::new(); + for group in new_playlist { + let mut any = false; + for (index, pattern) in watches.iter().enumerate() { + if pattern.is_match(&group.title) { + matched[index] = true; + any = true; + } + } + if any { + watched_groups.push(group); + } + } + + // A pattern that matches nothing looks exactly like a group that has not + // changed. `EventKindMask::from_wire_names` already reports unmatched + // subscription names for the same reason: a typo must surface. + let unmatched: Vec = watches + .iter() + .enumerate() + .filter(|(index, _)| !matched[*index]) + .map(|(_, pattern)| pattern.as_str().to_string()) + .collect(); + if !unmatched.is_empty() { + warn!("target '{}' has {} watch pattern(s) matching no group", target.name, unmatched.len()); + events.emit(EventMessage::PlaylistWatchUnmatched(WatchUnmatched::new( + target.name.clone(), + unmatched, + new_playlist.len(), + ))); + } + + futures::stream::iter( + watched_groups.into_iter().map(|pl| process_group_watch(app_config, events, &target.name, pl)), + ) + .for_each_concurrent(16, |f| f) + .await; + + true +} diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs new file mode 100644 index 000000000..cbf9e2fbc --- /dev/null +++ b/backend/processing/src/processor/playlist/tests.rs @@ -0,0 +1,1350 @@ +#![allow(clippy::wildcard_imports)] +use super::*; +use shared::{ + foundation::{get_filter, MapperScript, ValueProvider}, + model::{ + ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor, + ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType, + XtreamCluster, XtreamPlaylistItem, + }, + utils::Internable, +}; +use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config}; + +fn serialize_without_trailing_fields(value: &T, trailing_fields: &[u8]) -> Vec { + let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize"); + for expected in trailing_fields { + assert_eq!(encoded.pop(), Some(*expected), "unexpected trailing MessagePack field"); + } + let removed = trailing_fields.len(); + match encoded[0] { + marker @ 0x92..=0x9f => { + let len = usize::from(marker - 0x90); + assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); + encoded[0] = 0x90 + u8::try_from(len - removed).unwrap_or_default(); + } + 0xdc => { + let len = u16::from_be_bytes([encoded[1], encoded[2]]); + let removed = u16::try_from(removed).unwrap_or(u16::MAX); + assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); + encoded[1..3].copy_from_slice(&(len - removed).to_be_bytes()); + } + 0xdd => { + let len = u32::from_be_bytes([encoded[1], encoded[2], encoded[3], encoded[4]]); + let removed = u32::try_from(removed).unwrap_or(u32::MAX); + assert!(len >= removed, "trailing field count exceeds MessagePack sequence length"); + encoded[1..5].copy_from_slice(&(len - removed).to_be_bytes()); + } + marker => panic!("unexpected MessagePack sequence marker {marker:#x}"), + } + encoded +} + +fn item_with_props(props: StreamProperties) -> PlaylistItem { + let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() }; + PlaylistItem { header } +} + +fn live_item_with_probe_timestamp_and_bitrate(last_probed_timestamp: i64, bitrate: u32) -> PlaylistItem { + item_with_props(StreamProperties::Live(Box::new(shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + bitrate, + last_probed_timestamp: Some(last_probed_timestamp), + ..Default::default() + }))) +} + +#[test] +fn rename_preserves_input_stream_id_captured_at_target_boundary() { + let mut item = PlaylistItem { + header: PlaylistItemHeader { + id: "origin-alpha".intern(), + url: "http://provider.example/channel.m3u8".intern(), + ..Default::default() + }, + }; + item.header.freeze_input_stream_id(); + let rename = ConfigRename::from(&ConfigRenameDto { + field: ItemField::Url, + pattern: "provider".to_string(), + new_name: "target".to_string(), + t_pattern: None, + }); + + exec_rename(&mut item, Some(&vec![rename])); + + assert_eq!(item.header.url.as_ref(), "http://target.example/channel.m3u8"); + assert_eq!(item.header.input_stream_id.as_ref(), "origin-alpha"); +} + +#[test] +fn mapper_changes_id_without_changing_frozen_input_stream_id() { + let mut item = PlaylistItem { + header: PlaylistItemHeader { id: "origin-alpha".intern(), name: "Channel".intern(), ..Default::default() }, + }; + item.header.freeze_input_stream_id(); + let mapping = CompiledMapping { + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), + program: MappingProgram::Script( + MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), + ), + }], + ..Default::default() + }; + + let outcome = map_channel(item, &mapping); + + assert_eq!(outcome.matched_rules, 1); + assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); + assert_eq!(outcome.channel.header.input_stream_id.as_ref(), "origin-alpha"); +} + +#[test] +fn mapper_cannot_resurrect_missing_legacy_input_stream_id_from_target_id() { + let mut source = PlaylistItem { + header: PlaylistItemHeader { + id: "80510".intern(), + url: "http://provider.example/live/user/pass/80510.ts".intern(), + input_name: "input".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..Default::default() + }, + }; + source.header.freeze_input_stream_id(); + let mut legacy_xtream = XtreamPlaylistItem::from(&source); + legacy_xtream.provider_id = 0; + legacy_xtream.input_stream_id = "".intern(); + legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern(); + let mut legacy_item = PlaylistItem::from(&legacy_xtream); + legacy_item.header.freeze_input_stream_id(); + let mapping = CompiledMapping { + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), + program: MappingProgram::Script( + MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), + ), + }], + ..Default::default() + }; + + let outcome = map_channel(legacy_item, &mapping); + let materialized_m3u = M3uPlaylistItem::from(&outcome.channel); + let materialized_xtream = XtreamPlaylistItem::from(&outcome.channel); + + assert_eq!(outcome.matched_rules, 1); + assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); + assert_eq!(outcome.channel.get_input_stream_id(), None); + assert!(materialized_m3u.provider_id.is_empty()); + assert_eq!(materialized_m3u.get_input_stream_id(), None); + assert_eq!(materialized_xtream.provider_id, 0); + assert_eq!(materialized_xtream.get_input_stream_id(), None); +} + +#[test] +fn execute_pipe_freezes_input_stream_id_without_rename_or_mapper() { + let input = ConfigInput::default(); + let item = PlaylistItem { header: PlaylistItemHeader { id: "origin-alpha".intern(), ..Default::default() } }; + let source = MemoryPlaylistSource::new(vec![PlaylistGroup { + id: 1, + title: "Group".intern(), + channels: vec![item], + xtream_cluster: XtreamCluster::Live, + }]) + .into_source(); + let mut fetched = FetchedPlaylist { input: &input, source, epg: None }; + let mut duplicates = HashSet::new(); + let target = ConfigTarget::from(&ConfigTargetDto::default()); + + let (mut processed, _outcome) = + execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false).expect("target processing should succeed"); + let mut groups = processed.source.take_groups(); + + assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha"); + assert!(groups[0].channels[0].header.set_field("id", "late-target-id")); + assert_eq!(groups[0].channels[0].header.input_stream_id.as_ref(), "origin-alpha"); +} + +#[test] +fn legacy_messagepack_playlist_items_default_missing_input_stream_id() { + let mut source = PlaylistItem { + header: PlaylistItemHeader { + id: "origin-alpha".intern(), + url: "http://provider.example/live/user/pass/80510.ts".intern(), + input_name: "input".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + ..Default::default() + }, + }; + + let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]); + let decoded_header: PlaylistItemHeader = + rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize"); + assert!(decoded_header.input_stream_id.is_empty()); + assert_eq!(decoded_header.get_input_stream_id(), None); + assert_eq!(decoded_header.upstream_user_agent, None); + let mut decoded_header = decoded_header; + decoded_header.freeze_input_stream_id(); + assert_eq!(decoded_header.get_input_stream_id().as_deref(), Some("origin-alpha")); + + source.header.freeze_input_stream_id(); + let mut m3u_item = M3uPlaylistItem::from(&source); + m3u_item.input_stream_id = "".intern(); + let m3u_bytes = serialize_without_trailing_fields(&m3u_item, &[0xc0, 0xa0]); + let decoded_m3u: M3uPlaylistItem = rmp_serde::from_slice(&m3u_bytes).expect("legacy M3U item should deserialize"); + assert!(decoded_m3u.input_stream_id.is_empty()); + assert_eq!(decoded_m3u.get_input_stream_id().as_deref(), Some("origin-alpha")); + assert_eq!(decoded_m3u.upstream_user_agent, None); + + let mut xtream_item = XtreamPlaylistItem::from(&source); + xtream_item.input_stream_id = "".intern(); + let xtream_bytes = serialize_without_trailing_fields(&xtream_item, &[0xc0, 0xa0]); + let decoded_xtream: XtreamPlaylistItem = + rmp_serde::from_slice(&xtream_bytes).expect("legacy Xtream item should deserialize"); + assert!(decoded_xtream.input_stream_id.is_empty()); + assert_eq!(decoded_xtream.get_input_stream_id().as_deref(), Some("80510")); + assert_eq!(decoded_xtream.upstream_user_agent, None); +} + +#[test] +fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() { + let source = PlaylistItem { + header: PlaylistItemHeader { + id: "80510".intern(), + input_stream_id: "origin-alpha".intern(), + ..Default::default() + }, + }; + + let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0])) + .expect("previous header should deserialize"); + let m3u: M3uPlaylistItem = + rmp_serde::from_slice(&serialize_without_trailing_fields(&M3uPlaylistItem::from(&source), &[0xc0])) + .expect("previous M3U item should deserialize"); + let xtream: XtreamPlaylistItem = + rmp_serde::from_slice(&serialize_without_trailing_fields(&XtreamPlaylistItem::from(&source), &[0xc0])) + .expect("previous Xtream item should deserialize"); + + assert_eq!(header.input_stream_id.as_ref(), "origin-alpha"); + assert_eq!(m3u.input_stream_id.as_ref(), "origin-alpha"); + assert_eq!(xtream.input_stream_id.as_ref(), "origin-alpha"); + assert_eq!(header.upstream_user_agent, None); + assert_eq!(m3u.upstream_user_agent, None); + assert_eq!(xtream.upstream_user_agent, None); +} + +#[test] +fn messagepack_playlist_items_preserve_upstream_user_agent() -> Result<(), Box> { + let source = PlaylistItem { + header: PlaylistItemHeader { upstream_user_agent: Some("Provider-UA".intern()), ..Default::default() }, + }; + + let header: PlaylistItemHeader = rmp_serde::from_slice(&rmp_serde::to_vec(&source.header)?)?; + let m3u: M3uPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&M3uPlaylistItem::from(&source))?)?; + let xtream: XtreamPlaylistItem = rmp_serde::from_slice(&rmp_serde::to_vec(&XtreamPlaylistItem::from(&source))?)?; + + assert_eq!(header.upstream_user_agent.as_deref(), Some("Provider-UA")); + assert_eq!(m3u.upstream_user_agent.as_deref(), Some("Provider-UA")); + assert_eq!(xtream.upstream_user_agent.as_deref(), Some("Provider-UA")); + Ok(()) +} + +#[test] +fn has_probe_details_requires_video_and_audio_for_video() { + let video = shared::model::VideoStreamProperties { + details: Some(shared::model::VideoStreamDetailProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: None, + ..Default::default() + }), + ..Default::default() + }; + let item_missing_audio = item_with_props(StreamProperties::Video(Box::new(video))); + assert!(!has_probe_details(&item_missing_audio)); + + let video_complete = shared::model::VideoStreamProperties { + details: Some(shared::model::VideoStreamDetailProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + ..Default::default() + }), + ..Default::default() + }; + let item_complete = item_with_props(StreamProperties::Video(Box::new(video_complete))); + assert!(has_probe_details(&item_complete)); +} + +#[test] +fn has_probe_details_requires_video_audio_and_bitrate_for_live() { + let live_missing_audio = shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: None, + ..Default::default() + }; + let item_missing_audio = item_with_props(StreamProperties::Live(Box::new(live_missing_audio))); + assert!(!has_probe_details(&item_missing_audio)); + + let live_missing_bitrate = shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + ..Default::default() + }; + let item_missing_bitrate = item_with_props(StreamProperties::Live(Box::new(live_missing_bitrate))); + assert!(!has_probe_details(&item_missing_bitrate)); + + let live_complete = shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + bitrate: 2_500_000, + ..Default::default() + }; + let item_complete = item_with_props(StreamProperties::Live(Box::new(live_complete))); + assert!(has_probe_details(&item_complete)); +} + +#[test] +fn needs_live_probe_when_fresh_probe_has_no_bitrate() { + let item = live_item_with_probe_timestamp_and_bitrate(101, 0); + + assert!(needs_live_probe(&item, 100)); +} + +#[test] +fn does_not_need_live_probe_when_fresh_probe_has_positive_bitrate() { + let item = live_item_with_probe_timestamp_and_bitrate(101, 2_500_000); + + assert!(!needs_live_probe(&item, 100)); +} + +#[test] +fn needs_live_probe_when_positive_bitrate_probe_is_older_than_cutoff() { + let item = live_item_with_probe_timestamp_and_bitrate(99, 2_500_000); + + assert!(needs_live_probe(&item, 100)); +} + +#[test] +fn has_probe_details_is_false_for_series() { + let series = shared::model::SeriesStreamProperties::default(); + let item = item_with_props(StreamProperties::Series(Box::new(series))); + assert!(!has_probe_details(&item)); +} + +#[test] +fn collect_effective_skip_clusters_uses_input_skip_flags() { + use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions}; + let input = ConfigInput { + name: "skip_live".intern(), + input_type: InputType::Xtream, + options: Some(ConfigInputOptions { + flags: ConfigInputFlags::SkipLive.into(), + ..ConfigInputOptions::defaults().clone() + }), + ..ConfigInput::default() + }; + let skip = collect_effective_skip_clusters(&input); + assert!(skip.contains(&XtreamCluster::Live)); + assert!(!skip.contains(&XtreamCluster::Video)); + assert!(!skip.contains(&XtreamCluster::Series)); +} + +#[test] +fn filter_skipped_clusters_removes_cached_groups() { + use tuliprox_core::model::{ConfigInputFlags, ConfigInputOptions}; + let live_item = PlaylistItem { + header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Live, ..Default::default() }, + }; + let vod_item = PlaylistItem { + header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Video, ..Default::default() }, + }; + + let groups = vec![ + PlaylistGroup { id: 1, title: "Live".intern(), channels: vec![live_item], xtream_cluster: XtreamCluster::Live }, + PlaylistGroup { id: 2, title: "Vod".intern(), channels: vec![vod_item], xtream_cluster: XtreamCluster::Video }, + ]; + + let source = MemoryPlaylistSource::new(groups).into_source(); + let input = ConfigInput { + name: "skip_live".intern(), + input_type: InputType::Xtream, + options: Some(ConfigInputOptions { + flags: ConfigInputFlags::SkipLive.into(), + ..ConfigInputOptions::defaults().clone() + }), + ..ConfigInput::default() + }; + + let mut filtered = filter_skipped_clusters_from_source(source, &input); + let filtered_groups = filtered.take_groups(); + assert_eq!(filtered_groups.len(), 1); + assert_eq!(filtered_groups[0].xtream_cluster, XtreamCluster::Video); +} + +fn test_group(cluster: XtreamCluster, item_name: &str, input_name: &str) -> PlaylistGroup { + PlaylistGroup { + id: 1, + title: item_name.intern(), + xtream_cluster: cluster, + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + name: item_name.intern(), + input_name: input_name.intern(), + xtream_cluster: cluster, + item_type: match cluster { + XtreamCluster::Live => PlaylistItemType::Live, + XtreamCluster::Video => PlaylistItemType::Video, + XtreamCluster::Series => PlaylistItemType::Series, + }, + ..Default::default() + }, + }], + } +} + +#[test] +fn staged_overlay_replaces_selected_clusters_and_rewrites_input_name() { + let provider_name = "provider".intern(); + let provider_groups = vec![ + test_group(XtreamCluster::Live, "provider-live", "provider"), + test_group(XtreamCluster::Video, "provider-vod", "provider"), + ]; + let staged_groups = vec![ + test_group(XtreamCluster::Live, "staged-live", "staged"), + test_group(XtreamCluster::Series, "staged-series", "staged"), + ]; + + let groups = apply_staged_overlay_groups(&provider_name, ClusterFlags::Live, provider_groups, staged_groups); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].title.as_ref(), "provider-vod"); + assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider"); + assert_eq!(groups[1].title.as_ref(), "staged-live"); + assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider"); +} + +#[test] +fn staged_overlay_is_skipped_when_provider_playlist_is_cached() { + let result = PlaylistDownloadResult::new(vec![], vec![], true, false); + + assert!(!should_apply_staged_overlay(&result)); +} + +fn make_test_item(name: &str, item_type: PlaylistItemType) -> PlaylistItem { + let header = + PlaylistItemHeader { name: name.into(), group: "Test Group".intern(), item_type, ..Default::default() }; + PlaylistItem { header } +} + +#[test] +fn test_filter_evalutes_correctly() { + let filter = get_filter(r#"name ~ "Allowed""#, None).unwrap(); + + let allowed_item = make_test_item("Allowed Channel", PlaylistItemType::Live); + let denied_item = make_test_item("Denied Channel", PlaylistItemType::Live); + + let allowed_provider = ValueProvider { pli: &allowed_item, match_as_ascii: false }; + let denied_provider = ValueProvider { pli: &denied_item, match_as_ascii: false }; + + assert!(filter.filter(&allowed_provider)); + assert!(!filter.filter(&denied_provider)); +} + +#[test] +fn test_filter_with_type_comparison() { + let filter = get_filter("type = vod", None).unwrap(); + + let vod_item = make_test_item("Test Movie", PlaylistItemType::Video); + let live_item = make_test_item("Test Channel", PlaylistItemType::Live); + + let vod_provider = ValueProvider { pli: &vod_item, match_as_ascii: false }; + let live_provider = ValueProvider { pli: &live_item, match_as_ascii: false }; + + assert!(filter.filter(&vod_provider)); + assert!(!filter.filter(&live_provider)); +} + +#[test] +fn playlist_retention_reports_filter_counts() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![ + make_test_item("Allowed", PlaylistItemType::Live), + make_test_item("Denied", PlaylistItemType::Live), + ], + xtream_cluster: XtreamCluster::Live, + }]; + let mut source = MemoryPlaylistSource::new(groups).into_source(); + + let (filtered, outcome) = retain_playlist_items(&mut source, |item| item.header.name.as_ref() == "Allowed"); + + assert_eq!(outcome, FilterOutcome { inspected: 2, retained: 1, removed: 1 }); + assert_eq!(filtered.expect("one item should remain")[0].channels[0].header.name.as_ref(), "Allowed"); +} + +#[test] +fn filter_stage_can_remove_every_item() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![make_test_item("Denied", PlaylistItemType::Live)], + xtream_cluster: XtreamCluster::Live, + }]; + let mut target = ConfigTarget::from(&ConfigTargetDto::default()); + target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into(); + + let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); + + assert!(groups.is_empty()); + assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 1, retained: 0, removed: 1 })); +} + +#[test] +fn missing_processing_filter_skips_filter_stage() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![make_test_item("Allowed", PlaylistItemType::Live)], + xtream_cluster: XtreamCluster::Live, + }]; + let target = ConfigTarget::from(&ConfigTargetDto::default()); + + let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].channels.len(), 1); + assert!(outcome.filter.is_none()); +} + +#[test] +fn missing_processing_filter_preserves_filter_stage_group_normalization() { + let mut first = make_test_item("One", PlaylistItemType::Live); + first.header.group = "News".intern(); + let mut second = make_test_item("Two", PlaylistItemType::Live); + second.header.group = "news".intern(); + let groups = vec![ + PlaylistGroup { id: 1, title: "News".intern(), channels: vec![first], xtream_cluster: XtreamCluster::Live }, + PlaylistGroup { id: 2, title: "news".intern(), channels: vec![second], xtream_cluster: XtreamCluster::Live }, + ]; + let target = ConfigTarget::from(&ConfigTargetDto::default()); + + let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].channels.len(), 2); + assert!(outcome.filter.is_none()); +} + +#[test] +fn pipeline_reports_filter_and_rename_outcomes() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![ + make_test_item("Allowed", PlaylistItemType::Live), + make_test_item("Denied", PlaylistItemType::Live), + ], + xtream_cluster: XtreamCluster::Live, + }]; + let mut target = ConfigTarget::from(&ConfigTargetDto::default()); + target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse").into(); + target.rename = Some(vec![ConfigRename::from(&ConfigRenameDto { + field: ItemField::Name, + pattern: "Allowed".to_string(), + new_name: "Renamed".to_string(), + t_pattern: None, + })]); + + let (groups, outcome) = + execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter, TransformStage::Rename]); + + assert_eq!(groups[0].channels[0].header.name.as_ref(), "Renamed"); + assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 2, retained: 1, removed: 1 })); + assert_eq!(outcome.rename, Some(RenameOutcome { inspected: 1, changed_items: 1, changed_fields: 1 })); +} + +#[test] +fn assign_channel_no_playlist_preserves_non_zero_chno() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 10, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, + ], + xtream_cluster: XtreamCluster::Live, + }, + PlaylistGroup { + id: 2, + title: "Group C".intern(), + channels: vec![ + PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 1, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } }, + ], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // Non-zero chno values must be preserved + assert_eq!(groups[0].channels[0].header.chno, 10); + assert_eq!(groups[1].channels[0].header.chno, 1); +} + +#[test] +fn assign_channel_no_playlist_assigns_zero_chno_only() { + let mut groups = vec![PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() } }, + ], + xtream_cluster: XtreamCluster::Live, + }]; + + assign_channel_no_playlist(&mut groups); + + // All zero-chno channels should get assigned numbers starting at 1 + assert_eq!(groups[0].channels[0].header.chno, 1); + assert_eq!(groups[0].channels[1].header.chno, 2); + assert_eq!(groups[0].channels[2].header.chno, 3); +} + +#[test] +fn assign_channel_no_playlist_skips_existing_nonzero_numbers() { + let mut groups = vec![PlaylistGroup { + id: 1, + title: "Group A".intern(), + channels: vec![ + PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 5, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "C".intern(), chno: 2, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "D".intern(), chno: 0, ..Default::default() } }, + ], + xtream_cluster: XtreamCluster::Live, + }]; + + assign_channel_no_playlist(&mut groups); + + // Existing non-zero numbers (2, 5) must be skipped when assigning new numbers + assert_eq!(groups[0].channels[0].header.chno, 5); // preserved + assert_eq!(groups[0].channels[2].header.chno, 2); // preserved + // B gets 1 (smallest available), D gets 3 (next available after 1 and existing 2) + assert_eq!(groups[0].channels[1].header.chno, 1); + assert_eq!(groups[0].channels[3].header.chno, 3); +} + +#[test] +fn assign_channel_no_playlist_assigns_following_group_order() { + let mut groups = vec![ + PlaylistGroup { + id: 1, + title: "Group 1".intern(), + channels: vec![ + PlaylistItem { header: PlaylistItemHeader { name: "A".intern(), chno: 0, ..Default::default() } }, + PlaylistItem { header: PlaylistItemHeader { name: "B".intern(), chno: 0, ..Default::default() } }, + ], + xtream_cluster: XtreamCluster::Live, + }, + PlaylistGroup { + id: 2, + title: "Group 2".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { name: "C".intern(), chno: 0, ..Default::default() }, + }], + xtream_cluster: XtreamCluster::Live, + }, + ]; + + assign_channel_no_playlist(&mut groups); + + // Numbers should follow iteration order across groups: A=1, B=2, C=3 + assert_eq!(groups[0].channels[0].header.chno, 1); + assert_eq!(groups[0].channels[1].header.chno, 2); + assert_eq!(groups[1].channels[0].header.chno, 3); +} + +#[tokio::test] +async fn parallel_input_scheduler_serializes_equal_groups_and_overlaps_distinct_groups() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + async fn observe(active: &AtomicUsize, maximum: &AtomicUsize) { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(20)).await; + active.fetch_sub(1, Ordering::SeqCst); + } + + let locks = tuliprox_core::utils::FileLockManager::default(); + let active = AtomicUsize::new(0); + let maximum = AtomicUsize::new(0); + tokio::join!( + with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), + with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), + ); + assert_eq!(maximum.load(Ordering::SeqCst), 1); + + maximum.store(0, Ordering::SeqCst); + tokio::join!( + with_sequential_group(&locks, Some(7), true, observe(&active, &maximum)), + with_sequential_group(&locks, Some(8), true, observe(&active, &maximum)), + ); + assert_eq!(maximum.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn parallel_input_scheduler_releases_group_after_abort() { + let locks = Arc::new(tuliprox_core::utils::FileLockManager::default()); + let task_locks = Arc::clone(&locks); + let task = tokio::spawn(async move { + with_sequential_group(&task_locks, Some(7), true, std::future::pending::<()>()).await; + }); + tokio::task::yield_now().await; + task.abort(); + let _ = task.await; + + tokio::time::timeout(Duration::from_secs(1), with_sequential_group(&locks, Some(7), true, std::future::ready(()))) + .await + .expect("aborting an input job must release its sequential group"); +} + +#[test] +fn input_progress_message_contains_each_target_and_blocking_input() { + let targets = ["target-a", "target-b"]; + let inputs = ["input-a", "input-b"]; + let messages: Vec<_> = targets + .iter() + .flat_map(|target| inputs.iter().map(move |input| target_waiting_message(target, input))) + .collect(); + + assert_eq!(messages.len(), 4); + for target in targets { + for input in inputs { + assert!(messages.contains(&format!("Target '{target}' is waiting for input '{input}'"))); + } + } + assert!(stalker_checkpoint_message("portal-a").contains("portal-a")); +} + +#[tokio::test] +async fn parallel_target_pipeline_bounds_active_finalizers() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let mut tasks = JoinSet::new(); + let mut results = Vec::new(); + let mut errors = Vec::new(); + + for index in 0..6 { + wait_for_target_finalizer_slot(&mut tasks, &mut results, &mut errors).await; + let active = Arc::clone(&active); + let maximum = Arc::clone(&maximum); + tasks.spawn(async move { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(10)).await; + active.fetch_sub(1, Ordering::SeqCst); + TargetJobResult { + index, + name: format!("target-{index}"), + result: Ok(()), + errors: Vec::new(), + processing: PipelineStats::default(), + } + }); + } + while let Some(result) = tasks.join_next().await { + collect_target_task_result(result, &mut results, &mut errors); + } + + assert!(maximum.load(Ordering::SeqCst) <= MAX_CONCURRENT_TARGET_FINALIZERS); + assert_eq!(results.len(), 6); + assert!(errors.is_empty()); +} + +#[test] +fn parallel_target_pipeline_normalizes_conflicting_output_resources() { + let config = Config { storage_dir: "/tmp/tuliprox-target-resources".to_string(), ..Config::default() }; + + let mut spaced = ConfigTarget::from(&ConfigTargetDto::default()); + spaced.name = "A B".to_string(); + let mut underscored = ConfigTarget::from(&ConfigTargetDto::default()); + underscored.name = "A_B".to_string(); + assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored))); + + spaced.name = "one".to_string(); + spaced.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput { + filename: Some("out/../x.m3u".to_string()), + include_type_in_url: false, + mask_redirect_url: false, + filter: None, + })]; + underscored.name = "two".to_string(); + underscored.output = vec![tuliprox_core::model::TargetOutput::M3u(tuliprox_core::model::M3uTargetOutput { + filename: Some("x.m3u".to_string()), + include_type_in_url: false, + mask_redirect_url: false, + filter: None, + })]; + assert!(!target_mutated_resources(&config, &spaced).is_disjoint(&target_mutated_resources(&config, &underscored))); +} + +mod mapping_stage { + use super::*; + use arc_swap::{ArcSwap, ArcSwapOption}; + use shared::model::{ConfigPaths, EpgSmartMatchConfigDto}; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::runtime::Runtime; + use tuliprox_core::{ + model::{ + EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, MediaToolCapabilities, PersistedEpgSource, + PersistedEpgSourceKind, SourcesConfig, + }, + utils::FileLockManager, + }; + + fn build_mapping(id: &str, stage: MappingStage, script: &str) -> CompiledMapping { + CompiledMapping { + id: id.to_string(), + match_as_ascii: false, + stage, + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter parses"), + program: MappingProgram::Script(MapperScript::parse(script, None).expect("script parses")), + }], + counters: vec![], + templates: None, + } + } + + fn build_target(mappings: Vec, remove_duplicates: bool) -> ConfigTarget { + let dto = ConfigTargetDto { + options: if remove_duplicates { + Some(ConfigTargetOptions { remove_duplicates, ..Default::default() }) + } else { + None + }, + ..Default::default() + }; + let mut target = ConfigTarget::from(&dto); + target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(CompiledTargetMappings::new( + mappings.into_iter().map(Arc::new).collect(), + ))))); + target + } + + /// Pinned to `NoopSink` rather than staying generic: these tests + /// exercise the pipeline, not the bus, and an inferred sink type + /// would just make every call site name one. + fn processing_context() -> PlaylistProcessingContext { + let paths = ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + }; + let config = AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), + hdhomerun: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::default()), + file_locks: Arc::new(FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(paths)), + custom_stream_response: Arc::new(ArcSwapOption::default()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(MediaToolCapabilities::new()), + }; + PlaylistProcessingContext { + client: reqwest::Client::new(), + config: Arc::new(config), + user_targets: Arc::new(ProcessTargets { + enabled: false, + inputs: Vec::new(), + targets: Vec::new(), + target_names: Vec::new(), + }), + events: shared::model::NoopSink, + playlist_state: None, + disabled_headers: None, + processed_inputs: Arc::new(Mutex::new(HashSet::new())), + input_locks: Arc::new(Mutex::new(HashMap::new())), + provider_manager: None, + metadata_manager: None, + pre_processed_inputs: None, + stalker_refresh_mode: StalkerRefreshMode::Complete, + partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + #[test] + fn persist_filter_runs_after_after_epg_mapping() { + let runtime = Runtime::new().expect("runtime"); + runtime.block_on(async { + let mut input = ConfigInput::from(ConfigInputDto::default()); + input.name = "input".intern(); + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + name: "Before".intern(), + group: "Live".intern(), + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + ..Default::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }]; + let mut playlist = + FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None }; + let rename = build_mapping("rename", MappingStage::AfterEpg, r#"@Name = "After""#); + let mut target = build_target(vec![rename], false); + target.filter.persist = Some(get_filter(r#"Name = "After""#, None).expect("filter parses")); + let mut stats = HashMap::from([( + Arc::clone(&input.name), + create_input_stat(1, 1, 0, input.input_type, &input.name, 0), + )]); + let mut errors = Vec::new(); + + let mut prepared = prepare_playlist_for_target( + &processing_context(), + std::slice::from_mut(&mut playlist), + &target, + &mut stats, + &mut errors, + false, + ) + .await + .expect("target preparation"); + + assert!(errors.is_empty()); + apply_persist_filter(&target, &mut prepared.playlist); + let item = &prepared.playlist[0].channels[0]; + assert_eq!(item.header.name.as_ref(), "After"); + }); + } + + fn make_channel(name: &str) -> PlaylistItem { + let mut item = PlaylistItem { + header: PlaylistItemHeader { + name: name.intern(), + group: "Originals".intern(), + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + ..Default::default() + }, + }; + item.header.freeze_input_stream_id(); + item + } + + fn memory_source(channels: Vec) -> PlaylistSource { + MemoryPlaylistSource::new(vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels, + xtream_cluster: XtreamCluster::Live, + }]) + .into_source() + } + + fn channel_count(source: &mut PlaylistSource) -> usize { + source.take_groups().iter().map(|g| g.channels.len()).sum() + } + + #[test] + fn map_playlist_applies_only_the_requested_stage() { + let processing = build_mapping("processing", MappingStage::Processing, r#"@name = concat(@Name, "-P")"#); + let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"@name = concat(@Name, "-E")"#); + let target = build_target(vec![processing, after_epg], false); + + let mut source = memory_source(vec![make_channel("Alpha")]); + let (groups, _) = execute_pipeline_on_groups(source.take_groups(), &target, &[TransformStage::Map]); + assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P"); + + let mut source = MemoryPlaylistSource::new(groups).into_source(); + let groups = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None) + .expect("after_epg mapping should run"); + assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P-E"); + } + + #[test] + fn map_playlist_at_stage_returns_none_without_consuming_source_when_no_match() { + let target = build_target(Vec::new(), false); + let mut source = memory_source(vec![make_channel("Alpha")]); + + let result = map_playlist_at_stage(&mut source, &target, MappingStage::AfterEpg, None); + assert!(result.is_none(), "no matching stage must return None"); + assert_eq!(channel_count(&mut source), 1, "source must remain intact"); + } + + #[test] + fn prepare_target_applies_after_epg_mapping_before_sampling_stats() { + let runtime = Runtime::new().expect("runtime"); + runtime.block_on(async { + let dir = tempdir().expect("tempdir"); + let ics_path = dir.path().join("bbc.ics"); + std::fs::write( + &ics_path, + "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR", + ) + .expect("write ics"); + + let mut smart_dto = EpgSmartMatchConfigDto { + enabled: true, + fuzzy_matching: false, + ..EpgSmartMatchConfigDto::default() + }; + smart_dto.prepare().expect("smart config"); + let mut input = ConfigInput::from(ConfigInputDto::default()); + input.name = "input".intern(); + input.epg = Some(EpgConfig { + sources: vec![], + smart_match: Some(EpgSmartMatchConfig::from(smart_dto)), + }); + + let channels = vec![live_item_for_epg("BBC One")]; + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels, + xtream_cluster: XtreamCluster::Live, + }]; + let tv_guide = TVGuide::new(vec![PersistedEpgSource { + file_path: ics_path, + priority: 0, + logo_override: false, + kind: PersistedEpgSourceKind::Ics { + channel_id: "bbc.one".intern(), + channel_title: Some("BBC One".intern()), + match_names: vec!["BBC One".intern()], + config: Box::new(IcsEpgSourceConfig::default()), + }, + }]); + + let mut playlist = FetchedPlaylist { + input: &input, + source: MemoryPlaylistSource::new(groups).into_source(), + epg: Some(tv_guide), + }; + + let rename_from_epg = build_mapping( + "rename", + MappingStage::AfterEpg, + r#"epg = @epg_channel_id ~ "(.+)" +match { + epg => @Name = epg.1 +}"#, + ); + let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); + let target = build_target(vec![rename_from_epg, add_virtual], false); + let mut stats = HashMap::from([( + Arc::clone(&input.name), + create_input_stat(1, 1, 0, input.input_type, &input.name, 0), + )]); + let mut errors = Vec::new(); + let prepared = prepare_playlist_for_target( + &processing_context(), + std::slice::from_mut(&mut playlist), + &target, + &mut stats, + &mut errors, + false, + ) + .await + .expect("target preparation"); + + assert!(errors.is_empty()); + assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::(), 2); + let channel = prepared + .playlist + .iter() + .flat_map(|group| &group.channels) + .find(|channel| channel.header.group.as_ref() != "Echo") + .expect("original channel"); + assert_eq!(channel.header.epg_channel_id.as_deref(), Some("bbc.one")); + assert_eq!( + channel.header.name.as_ref(), + "bbc.one", + "after_epg mapper must consume the EPG-enriched field" + ); + let processed_stats = &stats[&input.name].processed_stats; + assert_eq!(processed_stats.group_count, 2); + assert_eq!(processed_stats.channel_count, 2); + }); + } + + #[test] + fn clear_invalid_epg_ids_clears_ids_invalidated_by_after_epg_mapping() { + let runtime = Runtime::new().expect("runtime"); + runtime.block_on(async { + let dir = tempdir().expect("tempdir"); + let ics_path = dir.path().join("bbc.ics"); + std::fs::write( + &ics_path, + "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:News\nDTSTART:20260306T120000Z\nDTEND:20260306T130000Z\nEND:VEVENT\nEND:VCALENDAR", + ) + .expect("write ics"); + + let mut input = ConfigInput::from(ConfigInputDto::default()); + input.name = "input".intern(); + input.epg = Some(EpgConfig { sources: vec![], smart_match: None }); + let groups = vec![PlaylistGroup { + id: 1, + title: "Live".intern(), + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + name: "BBC One".intern(), + epg_channel_id: Some("bbc.one".intern()), + group: "Live".intern(), + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + ..Default::default() + }, + }], + xtream_cluster: XtreamCluster::Live, + }]; + let tv_guide = TVGuide::new(vec![PersistedEpgSource { + file_path: ics_path, + priority: 0, + logo_override: false, + kind: PersistedEpgSourceKind::Ics { + channel_id: "bbc.one".intern(), + channel_title: Some("BBC One".intern()), + match_names: vec![], + config: Box::new(IcsEpgSourceConfig::default()), + }, + }]); + let mut playlist = FetchedPlaylist { + input: &input, + source: MemoryPlaylistSource::new(groups).into_source(), + epg: Some(tv_guide), + }; + + let rewrite_epg = + build_mapping("rewrite", MappingStage::AfterEpg, r#"@epg_channel_id = "missing.epg""#); + let add_virtual = build_mapping("virtual", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); + let mut target = build_target(vec![rewrite_epg, add_virtual], false); + target.options = Some(ConfigTargetOptions { clear_invalid_epg_ids: true, ..Default::default() }); + let mut stats = HashMap::from([( + Arc::clone(&input.name), + create_input_stat(1, 1, 0, input.input_type, &input.name, 0), + )]); + let mut errors = Vec::new(); + + let prepared = prepare_playlist_for_target( + &processing_context(), + std::slice::from_mut(&mut playlist), + &target, + &mut stats, + &mut errors, + false, + ) + .await + .expect("target preparation"); + + assert!(errors.is_empty()); + assert!(!prepared.playlist.is_empty()); + assert!(prepared + .playlist + .iter() + .flat_map(|group| &group.channels) + .all(|channel| channel.header.epg_channel_id.is_none())); + assert_eq!(stats[&input.name].processed_stats.channel_count, 2); + }); + } + + fn live_item_for_epg(name: &str) -> PlaylistItem { + PlaylistItem { + header: PlaylistItemHeader { + name: name.intern(), + group: "Live".intern(), + xtream_cluster: XtreamCluster::Live, + item_type: PlaylistItemType::Live, + ..Default::default() + }, + } + } + + #[test] + fn after_epg_hook_runs_on_source_already_deduplicated_by_processing_pipe() { + let processing = build_mapping("processing", MappingStage::Processing, r#"@group = "PROCESSED""#); + let after_epg = build_mapping("after_epg", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); + let target = build_target(vec![processing, after_epg], true); + + let input = ConfigInput::default(); + let channel = make_channel("Alpha"); + let mut fetched = + FetchedPlaylist { input: &input, source: memory_source(vec![channel.clone(), channel]), epg: None }; + let mut duplicates = HashSet::new(); + let (mut processed, _outcome) = + execute_pipe(&target, &get_processing_pipe(&target), &mut fetched, &mut duplicates, false) + .expect("processing pipe must run"); + assert_eq!(processed.get_channel_count(), 1, "processing pipe must remove the duplicate"); + + let groups = map_playlist_at_stage(&mut processed.source, &target, MappingStage::AfterEpg, None) + .expect("after_epg hook must run"); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].title.as_ref(), "PROCESSED"); + assert_eq!(groups[0].channels.len(), 1); + assert_eq!(groups[1].title.as_ref(), "Echo"); + assert_eq!(groups[1].channels.len(), 1); + } + + #[test] + fn prepare_target_deduplicates_virtual_items_created_by_after_epg_mappings() { + let runtime = Runtime::new().expect("runtime"); + runtime.block_on(async { + let first = build_mapping("first", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); + let second = build_mapping("second", MappingStage::AfterEpg, r#"add_favourite("Echo")"#); + let target = build_target(vec![first, second], true); + let input = ConfigInput { name: "input".intern(), ..Default::default() }; + let mut playlist = + FetchedPlaylist { input: &input, source: memory_source(vec![make_channel("Alpha")]), epg: None }; + let mut stats = HashMap::from([( + Arc::clone(&input.name), + create_input_stat(1, 1, 0, input.input_type, &input.name, 0), + )]); + let mut errors = Vec::new(); + + let prepared = prepare_playlist_for_target( + &processing_context(), + std::slice::from_mut(&mut playlist), + &target, + &mut stats, + &mut errors, + false, + ) + .await + .expect("target preparation"); + + assert!(errors.is_empty()); + assert_eq!(prepared.playlist.iter().map(|group| group.channels.len()).sum::(), 3); + assert_eq!(stats[&input.name].processed_stats.channel_count, 3); + }); + } +} + +#[cfg(test)] +mod disk_epg_wireup_tests { + use super::spill_epg_to_disk; + use shared::model::EpgChannel; + use std::sync::Arc; + use tuliprox_core::model::Epg; + + /// Build an `Epg` with `channel_count` channels whose ids follow the + /// `id_base` prefix. Two sources built with the same `id_base` and + /// `channel_count` will share all channel ids, which is what we need to + /// exercise the priority-override `Occupied` branch in + /// `EpgMergeAccumulator::upsert_channel`. + fn build_epg(id_base: &str, priority: i16, channel_count: usize) -> Epg { + Epg { + priority, + logo_override: false, + attributes: None, + children: (0..channel_count) + .map(|i| { + let id: Arc = format!("{id_base}-ch-{i:04}").into(); + Arc::new(EpgChannel { + id: Arc::clone(&id), + title: Some(format!("title-{priority}-{i}").into()), + icon: None, + programmes: vec![shared::model::EpgProgramme::new( + i64::try_from(i).expect("test index fits in i64"), + i64::try_from(i + 1).expect("test index fits in i64"), + id, + )], + }) + }) + .collect(), + } + } + + /// Wire-up regression guard: `spill_epg_to_disk` is the function called + /// by `finalize_prepared_target` when `disk_based_processing = true`. It + /// must (a) preserve per-source priority on shared channels, (b) clean up + /// its temp files, and (c) merge into a single `Epg` of the right size. + /// + /// Both sources share channel ids (`shared-ch-NNNN`), forcing the merge + /// to take the `Occupied` branch in `EpgMergeAccumulator::upsert_channel`. + /// The lower-priority source (priority 3) must win, the higher-priority + /// (priority 7) must be discarded for shared ids. Without this assertion + /// the test would pass even if priority resolution were broken — the + /// earlier version used unique ids and therefore never hit the merge path. + #[test] + fn spill_epg_to_disk_merges_shared_channels_by_priority() { + let epg_low = build_epg("shared", 3, 50); // wins on every shared channel + let epg_high = build_epg("shared", 7, 50); // discarded on every shared channel + + let merged = spill_epg_to_disk(vec![epg_low, epg_high]) + .expect("disk merge returned an error") + .expect("merged Epg is unexpectedly None for two non-empty sources"); + + // 50 distinct channels, not 100 — the merge must have collapsed the + // shared ids. + assert_eq!(merged.children.len(), 50, "shared channel ids must collapse to one entry, not be duplicated"); + + // Every channel title comes from the lower-priority source. If the + // merge logic is wrong, some titles will carry the "-7-" marker. + for ch in &merged.children { + let title = ch.title.as_deref().expect("title preserved through merge"); + assert!( + title.starts_with("title-3-"), + "channel {:?} kept title {title:?} from higher-priority source; \ + priority override is broken", + ch.id, + ); + // `add_channel_with_programmes` on the disk-merge path must + // preserve the lower-priority source's single programme per + // channel — `upsert_channel` would silently drop them. + assert_eq!(ch.programmes.len(), 1, "channel {:?} lost programmes through the disk-merge path", ch.id); + let prog = &ch.programmes[0]; + assert!(prog.title.is_none() || prog.title.as_deref() != Some("title-7")); + } + } + + /// The non-shared case: sources with disjoint channel ids. Both + /// sources' channels appear in the result with no priority loss (no + /// `Occupied` branch is taken). + #[test] + fn spill_epg_to_disk_keeps_disjoint_sources_intact() { + let epg_low = build_epg("src-a", 3, 50); + let epg_high = build_epg("src-b", 7, 50); + + let merged = spill_epg_to_disk(vec![epg_low, epg_high]) + .expect("disk merge returned an error") + .expect("merged Epg is unexpectedly None for two non-empty sources"); + + assert_eq!(merged.children.len(), 100, "disjoint ids must not collapse"); + assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-3-0"))); + assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-7-0"))); + } + + #[test] + fn spill_epg_to_disk_returns_none_for_empty_input() { + let merged = spill_epg_to_disk(vec![]).expect("disk merge returned an error"); + assert!(merged.is_none()); + } +} diff --git a/backend/processing/src/processor/playlist/transform.rs b/backend/processing/src/processor/playlist/transform.rs new file mode 100644 index 000000000..4ba23fa39 --- /dev/null +++ b/backend/processing/src/processor/playlist/transform.rs @@ -0,0 +1,534 @@ +#![allow(clippy::wildcard_imports)] +use super::*; + +pub(crate) fn retain_playlist_items( + source: &mut PlaylistSource, + mut keep: impl FnMut(&PlaylistItem) -> bool, +) -> (Option>, FilterOutcome) { + let mut groups: IndexMap = IndexMap::new(); + let mut outcome = FilterOutcome::default(); + for pli in source.into_items() { + if outcome.record(keep(&pli)) { + let group_title = pli.header.group.clone(); + let cluster = pli.header.xtream_cluster; + let cat_id = pli.header.category_id; + let normalized_group = shared::utils::deunicode_string(&group_title).to_lowercase().intern(); + let key = (cluster, normalized_group); + groups + .entry(key) + .or_insert_with(|| PlaylistGroup { + id: cat_id, + title: group_title, + channels: vec![], + xtream_cluster: cluster, + }) + .channels + .push(pli); + } + } + + let groups = if groups.is_empty() { None } else { Some(groups.into_values().collect()) }; + (groups, outcome) +} + +pub fn apply_filter_to_source(source: &mut PlaylistSource, filter: &Filter) -> Option> { + retain_playlist_items(source, |item| is_valid(item, filter, false)).0 +} + +pub(crate) fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { + let assigned_chnos: HashSet = + new_playlist.iter().flat_map(|g| &g.channels).filter(|c| c.header.chno != 0).map(|c| c.header.chno).collect(); + let mut chno = 1; + for group in new_playlist { + for chan in &mut group.channels { + if chan.header.chno == 0 { + while assigned_chnos.contains(&chno) { + chno += 1; + } + chan.header.chno = chno; + chno += 1; + } + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RenameOutcome { + pub inspected: usize, + pub changed_items: usize, + pub changed_fields: usize, +} + +#[derive(Debug, Default)] +pub struct PipelineOutcome { + pub filter: Option, + pub rename: Option, + pub mapping: Option, +} + +impl PipelineOutcome { + pub(crate) fn merge(&mut self, other: Self) { + if let Some(value) = other.filter { + let outcome = self.filter.get_or_insert_with(FilterOutcome::default); + outcome.inspected += value.inspected; + outcome.retained += value.retained; + outcome.removed += value.removed; + } + if let Some(value) = other.rename { + let outcome = self.rename.get_or_insert_with(RenameOutcome::default); + outcome.inspected += value.inspected; + outcome.changed_items += value.changed_items; + outcome.changed_fields += value.changed_fields; + } + if let Some(value) = other.mapping { + let outcome = self.mapping.get_or_insert_with(MappingStageOutcome::default); + outcome.inspected += value.inspected; + outcome.matched_rules += value.matched_rules; + outcome.emitted_items += value.emitted_items; + outcome.changed_fields.extend(value.changed_fields); + outcome.diagnostics += value.diagnostics; + outcome.reported_diagnostics += value.reported_diagnostics; + } + } + + pub(crate) fn to_stats(&self) -> PipelineStats { + PipelineStats { + inspected: self.filter.as_ref().map_or(0, |outcome| outcome.inspected), + retained: self.filter.as_ref().map_or(0, |outcome| outcome.retained), + removed: self.filter.as_ref().map_or(0, |outcome| outcome.removed), + renamed_items: self.rename.as_ref().map_or(0, |outcome| outcome.changed_items), + renamed_fields: self.rename.as_ref().map_or(0, |outcome| outcome.changed_fields), + matched_mapping_rules: self.mapping.as_ref().map_or(0, |outcome| outcome.matched_rules), + emitted_items: self.mapping.as_ref().map_or(0, |outcome| outcome.emitted_items), + mapping_diagnostics: self.mapping.as_ref().map_or(0, |outcome| outcome.diagnostics), + } + } +} + +pub(crate) fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) -> usize { + let mut changed_fields = 0; + if let Some(renames) = rename { + if !renames.is_empty() { + let result = pli; + for r in renames { + let value = get_field_value(result, r.field); + let cap = r.pattern.replace_all(&value, &r.new_name); + if log_enabled!(log::Level::Debug) && *value != *cap { + trace_if_enabled!("Renamed {}={value} to {cap}", &r.field); + } + if *value != *cap && set_field_value(result, r.field, cap.as_ref()) { + changed_fields += 1; + } + } + } + } + changed_fields +} + +pub(crate) struct ChannelMappingOutcome { + pub(crate) channel: PlaylistItem, + pub(crate) virtual_items: Vec, + pub(crate) matched_rules: usize, + pub(crate) changed_fields: HashSet, + pub(crate) diagnostics: Vec, +} + +pub(crate) const MAPPING_DIAGNOSTIC_LIMIT: usize = 10; + +#[derive(Debug, Default)] +pub struct MappingStageOutcome { + pub inspected: usize, + pub matched_rules: usize, + pub emitted_items: usize, + pub changed_fields: HashSet, + pub diagnostics: usize, + pub reported_diagnostics: usize, +} + +impl MappingStageOutcome { + pub(crate) fn record(&mut self, mapping_id: &str, outcome: &ChannelMappingOutcome) { + self.inspected += 1; + self.matched_rules += outcome.matched_rules; + self.emitted_items += outcome.virtual_items.len(); + self.changed_fields.extend(outcome.changed_fields.iter().cloned()); + self.diagnostics += outcome.diagnostics.len(); + for diagnostic in &outcome.diagnostics { + if self.reported_diagnostics >= MAPPING_DIAGNOSTIC_LIMIT { + break; + } + warn!("Mapping '{mapping_id}' {diagnostic}"); + self.reported_diagnostics += 1; + } + } +} + +pub(crate) fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome { + let mut matched_rules = 0; + let mut virtual_items = vec![]; + let mut changed_fields = HashSet::new(); + let mut diagnostics = Vec::new(); + if !mapping.rules.is_empty() { + let ref_chan = &mut channel; + let templates = mapping.templates.as_deref(); + for (rule_index, rule) in mapping.rules.iter().enumerate() { + let provider = ValueProvider { pli: ref_chan, match_as_ascii: mapping.match_as_ascii }; + if rule.filter.filter(&provider) { + matched_rules += 1; + let mut accessor = ValueAccessor { + pli: ref_chan, + virtual_items: vec![], + match_as_ascii: mapping.match_as_ascii, + changed_fields: vec![], + }; + let outcome = match &rule.program { + MappingProgram::Script(script) => script.eval(&mut accessor, templates), + }; + changed_fields.extend(outcome.changed_fields.iter().cloned()); + for diagnostic in outcome.diagnostics { + let rule_label = rule.name.as_deref().map_or_else(|| (rule_index + 1).to_string(), str::to_string); + diagnostics.push(format!( + "rule '{rule_label}' failed for channel '{}' at statement {}: {}", + accessor.pli.header.name, + diagnostic.statement + 1, + diagnostic.message + )); + } + virtual_items.extend(accessor.virtual_items.into_iter().map(|(_, pli)| pli)); + } + } + } + ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics } +} + +pub(crate) fn map_playlist_at_stage( + source: &mut PlaylistSource, + target: &ConfigTarget, + stage: MappingStage, + duplicates: Option<&mut HashSet>, +) -> Option> { + let mapping_binding = target.mapping.load(); + let mappings = mapping_binding.as_ref()?; + if mappings.for_stage(stage).is_empty() { + return None; + } + let items = source.into_items().collect::>(); + let (mapped_items, _outcome) = map_items_with_mappings_at_stage(items, mappings, stage, duplicates); + Some(group_mapped_items(mapped_items)) +} + +pub(crate) fn has_mapping_stage(target: &ConfigTarget, stage: MappingStage) -> bool { + target.mapping.load().as_ref().is_some_and(|mappings| !mappings.for_stage(stage).is_empty()) +} + +pub(crate) fn map_items_at_stage( + mapped_items: Vec, + target: &ConfigTarget, + stage: MappingStage, + duplicates: Option<&mut HashSet>, +) -> Option<(Vec, MappingStageOutcome)> { + let mapping_binding = target.mapping.load(); + let mappings = mapping_binding.as_ref()?; + (!mappings.for_stage(stage).is_empty()) + .then(|| map_items_with_mappings_at_stage(mapped_items, mappings, stage, duplicates)) +} + +fn map_items_with_mappings_at_stage( + mut mapped_items: Vec, + mappings: &tuliprox_core::model::CompiledTargetMappings, + stage: MappingStage, + duplicates: Option<&mut HashSet>, +) -> (Vec, MappingStageOutcome) { + let valid_mappings = mappings.for_stage(stage); + let original_ids = if duplicates.is_some() { + Some(mapped_items.iter().map(|item| *item.header.get_uuid()).collect::>()) + } else { + None + }; + let mut stage_outcome = MappingStageOutcome::default(); + for mapping in valid_mappings { + let mut next_items = Vec::with_capacity(mapped_items.len()); + for channel in mapped_items { + let outcome = map_channel(channel, mapping); + stage_outcome.record(&mapping.id, &outcome); + next_items.push(outcome.channel); + next_items.extend(outcome.virtual_items); + } + mapped_items = next_items; + } + debug!( + "Mapping stage {stage:?}: inspected={}, matched_rules={}, emitted={}, changed_fields={}, diagnostics={}, suppressed_diagnostics={}", + stage_outcome.inspected, + stage_outcome.matched_rules, + stage_outcome.emitted_items, + stage_outcome.changed_fields.len(), + stage_outcome.diagnostics, + stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics) + ); + let suppressed = stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics); + if suppressed > 0 { + warn!("Mapping stage {stage:?} suppressed {suppressed} additional diagnostics"); + } + if let (Some(original_ids), Some(duplicates)) = (original_ids, duplicates) { + mapped_items.retain(|item| { + let uuid = *item.header.get_uuid(); + original_ids.contains(&uuid) || duplicates.insert(uuid) + }); + } + (mapped_items, stage_outcome) +} + +pub(crate) fn group_mapped_items(items: Vec) -> Vec { + let mut groups: IndexMap = IndexMap::new(); + let mut group_id = 0; + for channel in items { + let group_title = channel.header.group.clone(); + let cluster = channel.header.xtream_cluster; + groups + .entry((cluster, group_title.clone())) + .or_insert_with(|| { + group_id += 1; + PlaylistGroup { id: group_id, title: group_title, channels: Vec::new(), xtream_cluster: cluster } + }) + .channels + .push(channel); + } + groups.into_values().collect() +} + +pub(crate) fn map_playlist_counter(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) { + if let Some(guard) = &*target.mapping.load() { + for mapping in &guard.all { + for counter in &mapping.counters { + // fresh per target/call. No shared atomic, no cross-refresh carry-over. + let mut current = counter.start; + for plg in &mut *playlist { + for channel in &mut plg.channels { + let provider = ValueProvider { pli: channel, match_as_ascii: mapping.match_as_ascii }; + if counter.filter.filter(&provider) { + let cntval = current; + current += 1; + let padded_cntval = if counter.padding > 0 { + format!("{:0width$}", cntval, width = counter.padding as usize) + } else { + cntval.to_string() + }; + let new_value = if counter.modifier == CounterModifier::Assign { + padded_cntval + } else { + let value = channel + .header + .get(counter.field) + .map_or_else(String::new, |field_value| field_value.as_cow().into_owned()); + if counter.modifier == CounterModifier::Suffix { + format!("{value}{}{padded_cntval}", counter.concat) + } else { + format!("{padded_cntval}{}{value}", counter.concat) + } + }; + channel.header.set(counter.field, new_value.as_str()); + } + } + } + } + } + } +} + +pub type ProcessingPipe = Vec; + +pub(crate) fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { + target.execution_plan.transform_stages.clone() +} + +#[derive(Clone, Copy)] +pub(crate) enum GroupingPolicy { + NormalizedCategory, + ExactCategory, + ExactSequential, +} + +pub(crate) struct TransformBuffer { + pub(crate) items: Vec, + pub(crate) grouping: GroupingPolicy, +} + +impl TransformBuffer { + pub(crate) fn new(items: Vec) -> Self { Self { items, grouping: GroupingPolicy::ExactCategory } } + + pub(crate) fn apply_filter(&mut self, target: &ConfigTarget) -> FilterOutcome { + let mut outcome = FilterOutcome::default(); + self.items.retain(|item| outcome.record(target.filter(&ValueProvider { pli: item, match_as_ascii: false }))); + self.normalize_filter_grouping(); + outcome + } + + pub(crate) fn normalize_filter_grouping(&mut self) { + self.grouping = GroupingPolicy::NormalizedCategory; + self.reorder_for_grouping(); + } + + pub(crate) fn apply_rename(&mut self, target: &ConfigTarget) -> Option { + let renames = target.rename.as_ref().filter(|renames| !renames.is_empty())?; + let mut outcome = RenameOutcome::default(); + for item in &mut self.items { + outcome.inspected += 1; + let changed_fields = exec_rename(item, Some(renames)); + outcome.changed_fields += changed_fields; + outcome.changed_items += usize::from(changed_fields > 0); + } + self.grouping = GroupingPolicy::ExactCategory; + self.reorder_for_grouping(); + Some(outcome) + } + + pub(crate) fn apply_mapping(&mut self, target: &ConfigTarget, stage: MappingStage) -> Option { + if !has_mapping_stage(target, stage) { + return None; + } + let items = std::mem::take(&mut self.items); + let (items, outcome) = map_items_at_stage(items, target, stage, None) + .expect("mapping stage applicability was checked before consuming the buffer"); + self.items = items; + self.grouping = GroupingPolicy::ExactSequential; + self.reorder_for_grouping(); + Some(outcome) + } + + pub(crate) fn reorder_for_grouping(&mut self) { + let mut buckets: IndexMap> = IndexMap::new(); + for item in std::mem::take(&mut self.items) { + let title = item.header.group.clone(); + let key_title = match self.grouping { + GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), + GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title, + }; + buckets.entry((item.header.xtream_cluster, key_title)).or_default().push(item); + } + self.items = buckets.into_values().flatten().collect(); + } + + pub(crate) fn into_groups(self) -> Vec { group_items(self.items, self.grouping) } +} + +pub(crate) fn group_items(items: Vec, policy: GroupingPolicy) -> Vec { + let mut groups: IndexMap = IndexMap::new(); + let mut next_group_id = 0; + for item in items { + let title = item.header.group.clone(); + let cluster = item.header.xtream_cluster; + let key_title = match policy { + GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), + GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title.clone(), + }; + groups + .entry((cluster, key_title)) + .or_insert_with(|| { + let id = match policy { + GroupingPolicy::ExactSequential => { + next_group_id += 1; + next_group_id + } + GroupingPolicy::NormalizedCategory | GroupingPolicy::ExactCategory => item.header.category_id, + }; + PlaylistGroup { id, title, channels: Vec::new(), xtream_cluster: cluster } + }) + .channels + .push(item); + } + groups.into_values().collect() +} + +pub(crate) fn execute_pipeline_on_items( + items: Vec, + target: &ConfigTarget, + pipe: &[TransformStage], +) -> (Vec, PipelineOutcome) { + let mut buffer = TransformBuffer::new(items); + let mut outcome = PipelineOutcome::default(); + for stage in pipe { + match stage { + TransformStage::Filter => { + if target.filter.processing.is_some() { + outcome.filter = Some(buffer.apply_filter(target)); + } else { + buffer.normalize_filter_grouping(); + } + } + TransformStage::Rename => outcome.rename = buffer.apply_rename(target), + TransformStage::Map => outcome.mapping = buffer.apply_mapping(target, MappingStage::Processing), + } + } + (buffer.into_groups(), outcome) +} + +pub(crate) fn execute_pipeline_on_groups( + groups: Vec, + target: &ConfigTarget, + pipe: &[TransformStage], +) -> (Vec, PipelineOutcome) { + if pipe.is_empty() { + return (groups, PipelineOutcome::default()); + } + execute_pipeline_on_items(groups.into_iter().flat_map(|group| group.channels).collect(), target, pipe) +} + +pub(crate) fn execute_pipe<'a>( + target: &ConfigTarget, + pipe: &ProcessingPipe, + fpl: &mut FetchedPlaylist<'a>, + duplicates: &mut HashSet, + consume_source: bool, +) -> Result<(FetchedPlaylist<'a>, PipelineOutcome), TuliproxError> { + let source = if consume_source { + if fpl.is_memory() { + MemoryPlaylistSource::new(fpl.source.take_groups()).into_source() + } else { + std::mem::replace(&mut fpl.source, MemoryPlaylistSource::default().into_source()) + } + } else { + fpl.clone_source()? + }; + + let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() }; + // In-memory items are frozen here at the target-processing boundary. Read-only disk sources + // capture the same identity when their persisted M3U/Xtream items are converted to PlaylistItem. + if new_fpl.is_memory() { + for item in new_fpl.items_mut() { + item.header.freeze_input_stream_id(); + } + } + if target.execution_plan.pre_transform_identity_dedup { + new_fpl.deduplicate(duplicates); + } + + let items = new_fpl.source.into_items().collect(); + let (groups, outcome) = execute_pipeline_on_items(items, target, pipe); + new_fpl.source = MemoryPlaylistSource::new(groups).into_source(); + Ok((new_fpl, outcome)) +} + +// This method is needed, because of duplicate group names in different inputs. +// We merge the same group names considering cluster together. +pub(crate) fn flatten_groups(playlistgroups: Vec) -> Vec { + let upper_bound = playlistgroups.len(); + let mut sort_order: Vec = Vec::with_capacity(upper_bound); + let mut idx: usize = 0; + let mut group_map: HashMap = HashMap::with_capacity(upper_bound); + for group in playlistgroups { + let normalized_title: Arc = shared::utils::deunicode_string(&group.title).to_lowercase().intern(); + let key = (group.xtream_cluster, normalized_title); + match group_map.entry(key) { + std::collections::hash_map::Entry::Vacant(v) => { + v.insert(idx); + idx += 1; + sort_order.push(group); + } + std::collections::hash_map::Entry::Occupied(o) => { + if let Some(pl_group) = sort_order.get_mut(*o.get()) { + pl_group.channels.extend(group.channels); + } + } + } + } + sort_order +} diff --git a/backend/repository/Cargo.toml b/backend/repository/Cargo.toml index bd3702752..b4159d61a 100644 --- a/backend/repository/Cargo.toml +++ b/backend/repository/Cargo.toml @@ -35,6 +35,7 @@ rmp-serde = "1.3.1" serde.workspace = true serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } tempfile = "3.27.0" +thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "parking_lot", "fs", "time", "macros", "sync"] } tokio-stream = { version = "0.1.19", features = ["sync"] } tokio-util = { version = "0.7.19", features = ["io", "io-util"] } diff --git a/backend/repository/src/error_macros.rs b/backend/repository/src/error_macros.rs index 0a674c36f..30d3ca5fb 100644 --- a/backend/repository/src/error_macros.rs +++ b/backend/repository/src/error_macros.rs @@ -17,6 +17,26 @@ //! `TuliproxError` is expected to be in scope at every call site (this crate //! always uses it via `shared::error::TuliproxError`). +#[inline] +pub(crate) fn format_repo_playlist_err( + action: &str, + label: &str, + path: &dyn std::fmt::Display, + err: &dyn std::fmt::Display, +) -> String { + format!("failed to {action} {label} playlist: {path} - {err}") +} + +#[inline] +pub(crate) fn format_repo_db_err( + action: &str, + label: &str, + path: &dyn std::fmt::Display, + err: &dyn std::fmt::Display, +) -> String { + format!("failed to {action} {label} db {path}: {err}") +} + /// Wrap an `io::Error` (or any `Display` value) into the canonical /// "failed to write {label} playlist: {path} - {err}" message and produce the /// matching `TuliproxError::Repository`. @@ -33,7 +53,12 @@ /// ``` macro_rules! cant_write_result { ($variant:ident, $label:literal, $path:expr, $err:expr $(,)?) => {{ - TuliproxError::$variant(format!("failed to write {} playlist: {} - {}", $label, $path.display(), $err)) + TuliproxError::$variant($crate::error_macros::format_repo_playlist_err( + "write", + $label, + &($path).display(), + &$err, + )) }}; } @@ -61,7 +86,12 @@ macro_rules! await_playlist_write { /// matching `TuliproxError::Repository`. macro_rules! cant_read_result { ($variant:ident, $label:literal, $path:expr, $err:expr $(,)?) => {{ - TuliproxError::$variant(format!("failed to read {} playlist: {} - {}", $label, $path.display(), $err)) + TuliproxError::$variant($crate::error_macros::format_repo_playlist_err( + "read", + $label, + &($path).display(), + &$err, + )) }}; } @@ -70,7 +100,7 @@ macro_rules! cant_read_result { /// matching `TuliproxError::Repository`. macro_rules! cant_open_result { ($variant:ident, $label:literal, $path:expr, $err:expr $(,)?) => {{ - TuliproxError::$variant(format!("failed to open {} db {}: {}", $label, $path.display(), $err)) + TuliproxError::$variant($crate::error_macros::format_repo_db_err("open", $label, &($path).display(), &$err)) }}; } @@ -79,7 +109,7 @@ macro_rules! cant_open_result { /// matching `TuliproxError::Repository`. macro_rules! cant_query_result { ($variant:ident, $label:literal, $path:expr, $err:expr $(,)?) => {{ - TuliproxError::$variant(format!("failed to query {} db {}: {}", $label, $path.display(), $err)) + TuliproxError::$variant($crate::error_macros::format_repo_db_err("query", $label, &($path).display(), &$err)) }}; } diff --git a/backend/repository/src/identity_registry.rs b/backend/repository/src/identity_registry.rs index 2f0866b88..27f36b1ca 100644 --- a/backend/repository/src/identity_registry.rs +++ b/backend/repository/src/identity_registry.rs @@ -389,30 +389,20 @@ impl IdentityRegistry { } } -/// Errors that can occur when mutating the registry. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum RegistryError { + #[error("username must not be empty")] EmptyUsername, /// The destination username already exists in the registry. The /// caller must remove or rename it explicitly before retrying — /// overwriting silently would discard the existing user's /// persisted recordings. + #[error("destination username already exists in the identity registry")] UsernameExists, - Persist(std::io::Error), + #[error("registry persistence failed: {0}")] + Persist(#[from] std::io::Error), } -impl std::fmt::Display for RegistryError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::EmptyUsername => f.write_str("username must not be empty"), - Self::UsernameExists => f.write_str("destination username already exists in the identity registry"), - Self::Persist(err) => write!(f, "registry persistence failed: {err}"), - } - } -} - -impl std::error::Error for RegistryError {} - /// Canonicalize a username for the registry: trim leading/trailing /// whitespace. Empty inputs are returned as-is so the caller can /// reject them through a typed error. diff --git a/backend/repository/src/provider_dns_repository.rs b/backend/repository/src/provider_dns_repository.rs index c37eadb12..75d535951 100644 --- a/backend/repository/src/provider_dns_repository.rs +++ b/backend/repository/src/provider_dns_repository.rs @@ -36,19 +36,12 @@ fn is_dns_writer_generation_current(generation: u64) -> bool { pub fn dns_resolved_file_path(storage_dir: &str) -> PathBuf { PathBuf::from(storage_dir).join(DNS_RESOLVED_FILE) } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum DnsResolvedStoreLoadError { - Read(std::io::Error), - Parse(serde_json::Error), -} - -impl std::fmt::Display for DnsResolvedStoreLoadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Read(err) => write!(f, "read failed: {err}"), - Self::Parse(err) => write!(f, "parse failed: {err}"), - } - } + #[error("read failed: {0}")] + Read(#[from] std::io::Error), + #[error("parse failed: {0}")] + Parse(#[from] serde_json::Error), } pub async fn load_dns_resolved_store_from_path( diff --git a/backend/session/src/active_user_manager.rs b/backend/session/src/active_user_manager.rs deleted file mode 100644 index 4c9675a57..000000000 --- a/backend/session/src/active_user_manager.rs +++ /dev/null @@ -1,9162 +0,0 @@ -use crate::{ - active_provider_manager::ConnectionKind, connection_manager::CleanupEvent, ActiveProviderManager, EventManager, -}; -use arc_swap::ArcSwapOption; -use jsonwebtoken::get_current_timestamp; -use log::{debug, info, log_enabled}; -use lru::LruCache; -use shared::{ - defaults::{ - default_grace_period_millis, default_grace_period_timeout_secs, default_hls_session_ttl_secs, DASH_EXT, HLS_EXT, - }, - model::{ - ActiveUserConnectionChange, CustomVideoStreamType, EventMessage, PlaylistItemType, StreamChannel, StreamInfo, - StreamTechnicalInfo, UserConnectionPermission, VirtualId, - }, - utils::{ - current_time_secs, extract_extension_from_url, is_catchup_session_token, sanitize_sensitive_info, strip_port, - Internable, - }, -}; -use std::{ - borrow::Cow, - cmp::Reverse, - collections::{BinaryHeap, HashMap, HashSet}, - net::SocketAddr, - num::NonZeroUsize, - sync::{ - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - Arc, - }, - time::{Duration, Instant}, -}; -use tokio::sync::{mpsc, Mutex, Notify, RwLock}; -use tokio_util::sync::CancellationToken; -use tuliprox_core::{ - model::{Config, Fingerprint, ProxyUserCredentials}, - utils::{debug_if_enabled, utc_day_from_secs}, -}; -use tuliprox_repository::GeoIp; - -/// Capacity of the per-user divergence cache. A constant so the conversion -/// cannot fail at runtime. -const DIVERGENCE_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(256).unwrap(); - -const USER_GC_TTL: u64 = 900; // 15 Min -const USER_CON_TTL: u64 = 1_800; // 30 minutes -const USER_SESSION_LIMIT: usize = 50; -const ANON_SOCKET_TTL: u64 = 300; // 5 Min -const DEFAULT_ACTIVE_SOCKET_TTL_SECS: u64 = 90; - -fn get_grace_options(config: &Config) -> (u64, u64) { - let (grace_period_millis, grace_period_timeout_secs) = - config.reverse_proxy.as_ref().and_then(|r| r.stream.as_ref()).map_or_else( - || (default_grace_period_millis(), default_grace_period_timeout_secs()), - |s| (s.grace_period_millis, s.grace_period_timeout_secs), - ); - (grace_period_millis, grace_period_timeout_secs) -} - -fn get_adaptive_session_ttl_secs(config: &Config) -> u64 { - config - .reverse_proxy - .as_ref() - .and_then(|r| r.stream.as_ref()) - .map_or_else(default_hls_session_ttl_secs, |s| s.hls_session_ttl_secs) -} - -fn stream_history_session_id(ts: u64, uid: u32) -> u64 { (ts << 32) | u64::from(uid) } - -fn decide_connection_kind( - counts: UserConnectionCounts, - max_connections: u32, - soft_connections: u16, -) -> Option { - if max_connections == 0 || counts.normal < max_connections { - return Some(ConnectionKind::Normal); - } - if soft_connections > 0 && counts.soft < soft_connections { - return Some(ConnectionKind::Soft); - } - None -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PendingProviderReason { - GraceHold, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PendingProviderWakeSource { - Activated, - Timeout, - CapacityNotify, - Cancelled, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PendingProviderState { - pub reason_code: PendingProviderReason, - pub created_at: u64, - pub deadline: u64, - pub version: u64, - pub wake_source: Option, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum PlaybackLifecycle { - #[default] - Prepared, - /// Waiting for a provider slot (`GraceMode::Hold`). The `data` field holds the pending state. - PendingProvider { - data: PendingProviderState, - }, - Active, - /// Provisional counted state for `GraceMode::Instant`. Counts against limits immediately - /// while the grace window resolves (success -> Active, failure -> Expired). - GraceActive, - Preserved, - Expired, -} - -impl PlaybackLifecycle { - /// Returns true for lifecycle states that own a counted admission lease. - /// Both `Active` and `GraceActive` count — `GraceActive` is a provisional - /// counted state for `GraceMode::Instant` sessions. - pub fn is_counted(&self) -> bool { matches!(self, Self::Active | Self::GraceActive) } -} - -#[derive(Clone, Debug)] -pub struct UserSession { - pub token: String, - pub transition_version: u64, - pub virtual_id: u32, - pub provider: Arc, - pub stream_url: Arc, - pub provider_session_headers: HashMap, - pub addr: SocketAddr, - pub socket_bound: bool, - pub active_addrs: Vec, - pub ts: u64, - pub started_at: u64, - pub permission: UserConnectionPermission, - pub connection_kind: Option, - pub lifecycle: PlaybackLifecycle, -} - -#[derive(Debug, Default, Clone, Copy)] -struct UserConnectionCounts { - normal: u32, - soft: u16, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ConnectionAdmission { - pub permission: UserConnectionPermission, - pub kind: Option, -} - -#[derive(Debug, Clone, Copy)] -struct PromotionAction { - addr: SocketAddr, - uid: u32, - new_priority: i8, -} - -#[derive(Debug)] -struct UserConnectionData { - max_connections: u32, - soft_connections: u16, - counts: UserConnectionCounts, - connections: u32, - granted_grace: bool, - grace_ts: u64, - sessions: Vec, - streams: Vec, - stream_kinds: HashMap, - stream_normal_priorities: HashMap, - ts: u64, -} - -impl UserConnectionData { - fn new(connections: u32, max_connections: u32, soft_connections: u16) -> Self { - Self { - max_connections, - soft_connections, - counts: UserConnectionCounts::default(), - connections, - granted_grace: false, - grace_ts: 0, - sessions: Vec::new(), - streams: Vec::new(), - stream_kinds: HashMap::new(), - stream_normal_priorities: HashMap::new(), - ts: current_time_secs(), - } - } - - fn add_session(&mut self, session: UserSession) { - self.gc(); - self.sessions.push(session); - } - fn gc(&mut self) { - if self.sessions.len() > USER_SESSION_LIMIT { - self.sessions.sort_by_key(|e| std::cmp::Reverse(e.ts)); - self.sessions.truncate(USER_SESSION_LIMIT); - } - } - - fn has_session_addr(&self, addr: &SocketAddr) -> bool { - self.sessions.iter().any(|session| session.addr == *addr || session.active_addrs.contains(addr)) - } - - fn release_addr_from_sessions(&mut self, addr: &SocketAddr) -> HashMap> { - let mut migrated_addrs = HashMap::new(); - for session in &mut self.sessions { - if session.addr == *addr || session.active_addrs.contains(addr) { - migrated_addrs.insert(session.token.clone(), release_session_addr(session, addr)); - } - } - migrated_addrs - } - - fn increment_kind(&mut self, kind: ConnectionKind) { - self.connections = self.connections.saturating_add(1); - match kind { - ConnectionKind::Normal => { - self.counts.normal = self.counts.normal.saturating_add(1); - } - ConnectionKind::Soft => { - self.counts.soft = self.counts.soft.saturating_add(1); - } - } - } - - fn decrement_kind(&mut self, kind: ConnectionKind) { - self.connections = self.connections.saturating_sub(1); - match kind { - ConnectionKind::Normal => { - self.counts.normal = self.counts.normal.saturating_sub(1); - } - ConnectionKind::Soft => { - self.counts.soft = self.counts.soft.saturating_sub(1); - } - } - } - - fn remove_streams_for_session_and_release_counted( - &mut self, - session_token: &str, - counted_kind: Option, - ) -> (u32, bool) { - let mut removed_count = 0; - let mut connection_changed = false; - let mut released_stream_kind = false; - let mut stream_idx = 0; - while stream_idx < self.streams.len() { - if self.streams[stream_idx].session_token.as_deref() != Some(session_token) { - stream_idx += 1; - continue; - } - - let uid = self.streams[stream_idx].uid; - if let Some(kind) = self.stream_kinds.remove(&uid) { - self.decrement_kind(kind); - released_stream_kind = true; - connection_changed = true; - } - self.stream_normal_priorities.remove(&uid); - self.streams.swap_remove(stream_idx); - removed_count += 1; - } - - if let Some(kind) = counted_kind.filter(|_| !released_stream_kind) { - self.decrement_kind(kind); - connection_changed = true; - } - - (removed_count, connection_changed) - } - - fn try_promote_soft_stream(&mut self) -> Option { - if self.counts.normal >= self.max_connections - || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) - { - return None; - } - - let candidate_uid = self - .streams - .iter() - .filter(|stream| !stream.preserved) - .filter_map(|stream| { - let kind = self.stream_kinds.get(&stream.uid).copied()?; - if kind != ConnectionKind::Soft { - return None; - } - let normal_priority = self.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default(); - Some((normal_priority, stream.ts, stream.uid, stream.addr)) - }) - .min_by_key(|(normal_priority, ts, uid, _)| (*normal_priority, *ts, *uid)); - - let (new_priority, _ts, uid, addr) = candidate_uid?; - - self.counts.normal = self.counts.normal.saturating_add(1); - if self.counts.soft > 0 { - self.counts.soft -= 1; - } - self.stream_kinds.insert(uid, ConnectionKind::Normal); - - Some(PromotionAction { addr, uid, new_priority }) - } - - fn try_promote_soft_session_reservation(&mut self) -> bool { - if self.counts.normal >= self.max_connections - || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) - { - return false; - } - - let active_tokens = - self.streams.iter().filter_map(|stream| stream.session_token.as_deref()).collect::>(); - - let candidate_index = self.sessions.iter().position(|session| { - session.lifecycle.is_counted() - && session.connection_kind == Some(ConnectionKind::Soft) - && !active_tokens.contains(session.token.as_str()) - }); - - let Some(candidate_index) = candidate_index else { - return false; - }; - - self.counts.normal = self.counts.normal.saturating_add(1); - if self.counts.soft > 0 { - self.counts.soft -= 1; - } - self.sessions[candidate_index].connection_kind = Some(ConnectionKind::Normal); - true - } - - fn effective_counts_for_admission(&self, exclude_session_token: Option<&str>) -> UserConnectionCounts { - let mut counts = self.counts; - let counted_tokens = self - .sessions - .iter() - .filter(|session| session.lifecycle.is_counted()) - .map(|session| session.token.as_str()) - .collect::>(); - let mut reserved_tokens = HashSet::new(); - - for stream in self.streams.iter().filter(|stream| stream.preserved) { - // Orphan preserved stream: no session token means no session to evict. - // Do not count it — it has no bearing on admission decisions. - let Some(session_token) = stream.session_token.as_deref() else { - continue; - }; - if exclude_session_token.is_some_and(|token| token == session_token) - || counted_tokens.contains(session_token) - || !reserved_tokens.insert(session_token) - { - continue; - } - - let kind = self - .sessions - .iter() - .find(|session| session.token == session_token) - .and_then(|session| session.connection_kind) - .unwrap_or(ConnectionKind::Normal); - match kind { - ConnectionKind::Normal => counts.normal = counts.normal.saturating_add(1), - ConnectionKind::Soft => counts.soft = counts.soft.saturating_add(1), - } - } - - counts - } -} - -fn create_socket_reentry_guard_key(username: &str, client_ip: &str, virtual_id: VirtualId) -> String { - shared::concat_string!(username, "|", client_ip, "|", &virtual_id.to_string()) -} - -fn is_stable_session_stream(stream: &StreamInfo) -> bool { - // Catchup-token Live/.ts segment sockets must preserve too; otherwise archive panel rows - // hard-remove every HLS chunk and Streams blinks even when frontend soft-preserve is present. - stream.channel.item_type == PlaylistItemType::Catchup - || stream.channel.item_type.is_live_adaptive() - || stream.session_token.as_deref().is_some_and(is_catchup_session_token) - || matches!( - extract_extension_from_url(stream.channel.url.as_ref()), - Some(ext) if ext == HLS_EXT || ext == DASH_EXT - ) -} - -fn uses_session_reentry_guard(stream: &StreamInfo) -> bool { - stream.channel.item_type.requires_provider_affinity() - || matches!( - extract_extension_from_url(stream.channel.url.as_ref()), - Some(ext) if ext == HLS_EXT || ext == DASH_EXT - ) -} - -#[derive(Clone, Copy, Debug)] -struct RecentWinnerProtection { - protected_addr: SocketAddr, - expires_at: u64, -} - -#[derive(Debug, Default)] -struct UserConnections { - kicked: HashMap, - recently_evicted_sessions: HashMap, - recent_socket_reentry_guards: HashMap, - by_key: HashMap, - key_by_addr: HashMap, -} - -#[derive(Clone, Debug)] -struct SocketRegistration { - username: String, - ts: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] -struct AdaptiveExpiryEntry { - expires_at: u64, - username: String, - session_token: String, - uid: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -struct AdaptiveExpiryKey { - username: String, - session_token: String, - uid: u32, -} - -pub struct ReleasedConnection { - pub addr_removed: bool, - pub removed_streams: Vec, - pub disconnected_users: Vec, -} - -pub struct ActiveUserConnectionParams<'a> { - pub uid: u32, - pub meter_uid: u32, - pub username: &'a str, - pub max_connections: u32, - pub soft_connections: u16, - pub connection_kind: ConnectionKind, - pub priority: i8, - pub soft_priority: i8, - pub fingerprint: &'a Fingerprint, - pub provider: Arc, - pub stream_channel: &'a StreamChannel, - pub user_agent: Cow<'a, str>, - pub session_token: Option<&'a str>, -} - -pub struct CreateUserSessionParams<'a> { - pub user: &'a ProxyUserCredentials, - pub session_token: &'a str, - pub virtual_id: u32, - pub provider: &'a str, - pub stream_url: &'a str, - pub addr: &'a SocketAddr, - pub connection_permission: UserConnectionPermission, - pub connection_kind: Option, - pub socket_bound: bool, -} - -fn remember_session_addr(session: &mut UserSession, addr: SocketAddr) { - if session.socket_bound { - session.active_addrs.clear(); - } else if let Some(position) = session.active_addrs.iter().position(|active_addr| *active_addr == addr) { - session.active_addrs.remove(position); - } - session.active_addrs.push(addr); - session.addr = addr; -} - -fn release_session_addr(session: &mut UserSession, addr: &SocketAddr) -> Option { - if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) { - session.active_addrs.remove(position); - } else if session.addr != *addr { - return None; - } - - if session.addr == *addr { - if let Some(next_addr) = session.active_addrs.last().copied() { - session.addr = next_addr; - return Some(next_addr); - } - } - - None -} - -fn clear_session_addr(session: &mut UserSession, addr: &SocketAddr) -> bool { - let mut changed = false; - if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) { - session.active_addrs.remove(position); - changed = true; - } - - if session.addr == *addr { - if let Some(next_addr) = session.active_addrs.last().copied() { - session.addr = next_addr; - } else { - session.addr = SocketAddr::from(([0, 0, 0, 0], 0)); - } - changed = true; - } - - changed -} - -impl SocketRegistration { - fn anonymous() -> Self { Self { username: String::new(), ts: current_time_secs() } } -} - -struct UserSessionParams<'a> { - session_token: &'a str, - virtual_id: u32, - provider: &'a str, - stream_url: &'a str, - addr: &'a SocketAddr, - connection_permission: UserConnectionPermission, - connection_kind: Option, - socket_bound: bool, -} - -pub struct ActiveUserManager { - grace_period_millis: AtomicU64, - grace_period_timeout_secs: AtomicU64, - adaptive_session_ttl_secs: AtomicU64, - log_active_user: AtomicBool, - gc_ts: Option, - connections: RwLock, - adaptive_expiry_queue: Arc>>>, - adaptive_expiry_index: Arc>>, - adaptive_expiry_notify: Arc, - adaptive_expiry_cancel: CancellationToken, - adaptive_expiry_worker_started: AtomicBool, - event_manager: Arc, - geo_ip: Arc>, - last_logged_user_count: AtomicUsize, - last_logged_user_connection_count: AtomicUsize, - cleanup_tx: tokio::sync::OnceCell>, - provider_manager: tokio::sync::OnceCell>, - transition_gates: Mutex>>>, - pub dropped_cleanup_events: AtomicU64, - divergence_cache: Mutex>, - divergence_cooldown_secs: u64, -} - -struct DivergenceEntry { - last_logged: Instant, - count_since_last_log: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -enum DivergenceKind { - CountedSessionWithoutStream, - StreamWithoutCountedSession, - ConnectionCountMismatch { legacy: u32, counted: u32 }, -} - -fn divergence_key(username: &str, kind: &DivergenceKind) -> String { - match kind { - DivergenceKind::CountedSessionWithoutStream => format!("{username}:CountedSessionWithoutStream"), - DivergenceKind::StreamWithoutCountedSession => format!("{username}:StreamWithoutCountedSession"), - DivergenceKind::ConnectionCountMismatch { legacy, counted } => { - format!("{username}:ConnectionCountMismatch:{legacy}+{counted}") - } - } -} - -struct DivergenceSnapshot { - username: String, - connections: u32, - counted_sessions: usize, - streams_count: usize, - kinds: Vec, -} - -impl ActiveUserManager { - pub fn shutdown(&self) { self.adaptive_expiry_cancel.cancel(); } - - pub fn start_adaptive_expiry_worker(self: &Arc) { - if self - .adaptive_expiry_worker_started - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) - .is_err() - { - return; - } - - let manager = Arc::clone(self); - tokio::spawn(async move { - manager.run_adaptive_expiry_worker().await; - }); - } - - fn lookup_country(&self, client_ip: &str) -> Option { - let geoip = self.geo_ip.load(); - (*geoip).as_ref().and_then(|geoip_db| geoip_db.lookup(&strip_port(client_ip))) - } - - fn custom_stream_technical_info() -> StreamTechnicalInfo { - StreamTechnicalInfo { - container: String::from("mpegts"), - resolution: String::new(), - fps: String::from("30"), - video_codec: String::from("H.264"), - audio_codec: String::from("AAC"), - audio_channels: String::from("Stereo"), - } - } - - /// The bus this manager publishes on. - /// - /// Exposed so the admission path can report a refusal without - /// `AdmissionCtx` growing a second handle to the same manager. - #[must_use] - pub fn events(&self) -> &Arc { &self.event_manager } - - pub fn new(config: &Config, geoip: &Arc>, event_manager: &Arc) -> Self { - let log_active_user: bool = config.log.as_ref().is_some_and(|l| l.log_active_user); - let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config); - - Self { - grace_period_millis: AtomicU64::new(grace_period_millis), - grace_period_timeout_secs: AtomicU64::new(grace_period_timeout_secs), - adaptive_session_ttl_secs: AtomicU64::new(get_adaptive_session_ttl_secs(config)), - log_active_user: AtomicBool::new(log_active_user), - connections: RwLock::new(UserConnections::default()), - adaptive_expiry_queue: Arc::new(Mutex::new(BinaryHeap::new())), - adaptive_expiry_index: Arc::new(Mutex::new(HashMap::new())), - adaptive_expiry_notify: Arc::new(Notify::new()), - adaptive_expiry_cancel: CancellationToken::new(), - adaptive_expiry_worker_started: AtomicBool::new(false), - gc_ts: Some(AtomicU64::new(current_time_secs())), - geo_ip: Arc::clone(geoip), - event_manager: Arc::clone(event_manager), - last_logged_user_count: AtomicUsize::new(0), - last_logged_user_connection_count: AtomicUsize::new(0), - cleanup_tx: tokio::sync::OnceCell::new(), - provider_manager: tokio::sync::OnceCell::new(), - transition_gates: Mutex::new(HashMap::new()), - dropped_cleanup_events: AtomicU64::new(0), - divergence_cache: Mutex::new(LruCache::new(DIVERGENCE_CACHE_CAPACITY)), - divergence_cooldown_secs: 300, - } - } - - fn transition_gate_key(username: &str, token: &str) -> String { - let mut key = String::with_capacity(username.len() + token.len() + 1); - key.push_str(username); - key.push('\0'); - key.push_str(token); - 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>>) { - transition_gates.retain(|_, gate| Arc::strong_count(gate) > 1); - } - - pub async fn acquire_playback_transition(&self, username: &str, token: &str) -> tokio::sync::OwnedMutexGuard<()> { - let key = Self::transition_gate_key(username, token); - 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 - } - - pub 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() - } - - pub fn set_cleanup_sender(&self, tx: mpsc::Sender) { let _ = self.cleanup_tx.set(tx); } - - pub fn set_provider_manager(&self, provider_manager: Arc) { - let _ = self.provider_manager.set(provider_manager); - } - - /// Collect a snapshot of all currently active streams for shutdown history recording. - pub async fn get_all_active_streams(&self) -> Vec { - let connections = self.connections.read().await; - connections - .by_key - .values() - .flat_map(|data| data.streams.iter().filter(|stream| !stream.preserved).cloned()) - .collect() - } - - async fn log_active_user(&self) { - let is_log_user_enabled = self.is_log_user_enabled(); - let (user_count, user_connection_count) = { self.active_users_and_connections().await }; - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connections( - user_count, - user_connection_count, - ))); - if !is_log_user_enabled { - return; - } - let last_user_count = self.last_logged_user_count.load(Ordering::Relaxed); - let last_connection_count = self.last_logged_user_connection_count.load(Ordering::Relaxed); - if last_user_count != user_count || last_connection_count != user_connection_count { - self.last_logged_user_count.store(user_count, Ordering::Relaxed); - self.last_logged_user_connection_count.store(user_connection_count, Ordering::Relaxed); - info!("Active Users: {user_count}, Active User Connections: {user_connection_count}"); - } - } - - async fn emit_promotion_update(&self, username: &str, action: PromotionAction) { - if let Some(provider_manager) = self.provider_manager.get() { - provider_manager.reclassify_connection(&action.addr, ConnectionKind::Normal, action.new_priority).await; - } - - let maybe_stream = { - let user_connections = self.connections.read().await; - user_connections.by_key.get(username).and_then(|connection_data| { - connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned() - }) - }; - if let Some(stream_info) = maybe_stream { - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); - } - } - - /// Releases an active stream for the given socket address without removing the - /// socket registration (`key_by_addr`). This is used when a stream ends while - /// the underlying HTTP connection may still remain open. - #[allow(clippy::too_many_lines)] - pub async fn release_stream(&self, addr: &SocketAddr) -> Option { - self.release_stream_inner(addr, None).await - } - - #[allow(clippy::too_many_lines)] - pub async fn release_stream_by_uid(&self, addr: &SocketAddr, stream_uid: u32) -> Option { - self.release_stream_inner(addr, Some(stream_uid)).await - } - - #[allow(clippy::too_many_lines)] - async fn release_stream_inner(&self, addr: &SocketAddr, stream_uid: Option) -> Option { - let ( - removed_stream, - username, - expiry_entry, - preserved_update, - connection_changed, - promotion, - divergence_snapshot, - ) = { - let mut user_connections = self.connections.write().await; - - let username = match stream_uid { - Some(uid) => user_connections.by_key.iter().find_map(|(username, connection_data)| { - connection_data - .streams - .iter() - .any(|stream| !stream.preserved && stream.uid == uid && stream.addr == *addr) - .then(|| username.clone()) - }), - None => user_connections - .key_by_addr - .get(addr) - .filter(|reg| !reg.username.is_empty()) - .map(|reg| reg.username.clone()) - .or_else(|| { - user_connections.by_key.iter().find_map(|(username, connection_data)| { - connection_data - .streams - .iter() - .any(|stream| !stream.preserved && stream.addr == *addr) - .then(|| username.clone()) - }) - }), - }?; - - let mut removed_stream = None; - let mut expiry_entry = None; - let mut preserved_update = None; - let mut connection_changed = false; - let mut promotion = None; - if let Some(connection_data) = user_connections.by_key.get_mut(&username) { - let migrated_session_addrs = connection_data.release_addr_from_sessions(addr); - if let Some(stream_idx) = connection_data.streams.iter().position(|stream| { - !stream.preserved - && stream_uid.map_or(stream.addr == *addr, |uid| stream.uid == uid && stream.addr == *addr) - }) { - let migrated_addr = connection_data.streams[stream_idx] - .session_token - .as_deref() - .and_then(|token| migrated_session_addrs.get(token)) - .copied() - .flatten(); - if let Some(next_addr) = migrated_addr { - connection_data.streams[stream_idx].addr = next_addr; - connection_data.streams[stream_idx].ts = current_time_secs(); - } else if Self::should_preserve_session_stream(&connection_data.streams[stream_idx]) { - let preserved_session_token = connection_data.streams[stream_idx].session_token.clone(); - if let Some(entry) = self.build_preserved_stream_expiry( - &username, - &connection_data.streams[stream_idx], - &connection_data.sessions, - ) { - if let Some(kind) = - connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) - { - connection_data.decrement_kind(kind); - connection_changed = true; - } - connection_data.stream_normal_priorities.remove(&connection_data.streams[stream_idx].uid); - if let Some(session_token) = preserved_session_token.as_deref() { - Self::clear_session_counted(connection_data, session_token); - } - connection_data.streams[stream_idx].preserved = true; - preserved_update = Some(connection_data.streams[stream_idx].clone()); - expiry_entry = Some(entry); - } else { - removed_stream = Some(connection_data.streams.swap_remove(stream_idx)); - } - } else { - removed_stream = Some(connection_data.streams.swap_remove(stream_idx)); - } - if let Some(removed_stream) = removed_stream.as_ref() { - if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) { - connection_data.decrement_kind(kind); - } - connection_data.stream_normal_priorities.remove(&removed_stream.uid); - connection_changed = true; - } - if connection_data.connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - if removed_stream.is_some() { - if let Some(action) = connection_data.try_promote_soft_stream() { - let promoted_stream = - connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); - if let Some(stream) = promoted_stream.as_ref() { - Self::promote_session_for_stream(connection_data, stream); - } - promotion = Some(action); - } - if let Some(session_token) = - removed_stream.as_ref().and_then(|stream| stream.session_token.as_deref()) - { - Self::clear_session_counted_without_stream(connection_data, session_token); - } - while connection_data.try_promote_soft_session_reservation() {} - } - } - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); - ( - removed_stream, - username, - expiry_entry, - preserved_update, - connection_changed, - promotion, - divergence_snapshot, - ) - } else { - (None, username, None, None, false, None, None) - } - }; - - self.log_divergence_snapshot(divergence_snapshot).await; - - if let Some(entry) = expiry_entry { - self.enqueue_adaptive_expiry(entry).await; - } - - if let Some(stream_info) = preserved_update { - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); - } - - if connection_changed { - if !username.is_empty() { - debug_if_enabled!( - "Released stream for user {username} at {}", - sanitize_sensitive_info(&addr.to_string()) - ); - } - self.log_active_user().await; - } - - if let Some(action) = promotion { - self.emit_promotion_update(&username, action).await; - } - - removed_stream - } - - #[allow(clippy::too_many_lines)] - async fn release_connection_inner(&self, addr: &SocketAddr, preserve_session_streams: bool) -> ReleasedConnection { - let ( - addr_removed, - connection_count_changed, - disconnected_users, - removed_streams, - expiry_entries, - preserved_updates, - promotions, - ) = { - let mut user_connections = self.connections.write().await; - - let registration = user_connections.key_by_addr.remove(addr); - let had_registration = registration.is_some(); - let mut disconnected_users = registration - .map(|registration| registration.username) - .filter(|username| !username.is_empty()) - .into_iter() - .collect::>(); - disconnected_users.extend( - user_connections - .by_key - .iter() - .filter(|(_, connection_data)| { - connection_data.has_session_addr(addr) - || connection_data.streams.iter().any(|stream| stream.addr == *addr) - }) - .map(|(username, _)| username.clone()), - ); - disconnected_users.sort_unstable(); - disconnected_users.dedup(); - - let mut removed_streams = Vec::new(); - let mut expiry_entries = Vec::new(); - let mut preserved_updates = Vec::new(); - let mut promotions = Vec::new(); - let mut connection_count_changed = false; - for username in &disconnected_users { - if let Some(connection_data) = user_connections.by_key.get_mut(username) { - let previous_connection_count = connection_data.connections; - let migrated_session_addrs = connection_data.release_addr_from_sessions(addr); - let mut remaining_streams = Vec::with_capacity(connection_data.streams.len()); - let mut released_kinds = Vec::new(); - let mut removed_session_tokens = HashSet::new(); - let mut preserved_session_tokens = Vec::new(); - let now = current_time_secs(); - for mut stream_info in connection_data.streams.drain(..) { - if stream_info.addr == *addr { - let migrated_addr = stream_info - .session_token - .as_deref() - .and_then(|token| migrated_session_addrs.get(token)) - .copied() - .flatten(); - if let Some(next_addr) = migrated_addr { - stream_info.addr = next_addr; - stream_info.ts = now; - remaining_streams.push(stream_info); - } else if preserve_session_streams && Self::should_preserve_session_stream(&stream_info) { - if let Some(entry) = self.build_preserved_stream_expiry( - username, - &stream_info, - &connection_data.sessions, - ) { - if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { - released_kinds.push(kind); - } - connection_data.stream_normal_priorities.remove(&stream_info.uid); - if let Some(token) = stream_info.session_token.as_ref() { - preserved_session_tokens.push(token.clone()); - } - if !stream_info.preserved { - stream_info.preserved = true; - preserved_updates.push(stream_info.clone()); - } - expiry_entries.push(entry); - remaining_streams.push(stream_info); - } else { - if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { - released_kinds.push(kind); - } - connection_data.stream_normal_priorities.remove(&stream_info.uid); - if let Some(token) = stream_info.session_token.as_ref() { - removed_session_tokens.insert(token.clone()); - } - removed_streams.push(stream_info); - } - } else { - if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { - released_kinds.push(kind); - } - connection_data.stream_normal_priorities.remove(&stream_info.uid); - if let Some(token) = stream_info.session_token.as_ref() { - removed_session_tokens.insert(token.clone()); - } - removed_streams.push(stream_info); - } - } else { - remaining_streams.push(stream_info); - } - } - connection_data.streams = remaining_streams; - if !preserve_session_streams && !removed_session_tokens.is_empty() { - connection_data.sessions.retain(|session| !removed_session_tokens.contains(&session.token)); - } - for kind in released_kinds { - connection_data.decrement_kind(kind); - } - while let Some(action) = connection_data.try_promote_soft_stream() { - let promoted_stream = - connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); - if let Some(stream) = promoted_stream.as_ref() { - Self::promote_session_for_stream(connection_data, stream); - } - promotions.push((username.clone(), action)); - } - for session_token in &removed_session_tokens { - Self::clear_session_counted_without_stream(connection_data, session_token); - } - for session_token in &preserved_session_tokens { - Self::clear_session_counted(connection_data, session_token); - } - while connection_data.try_promote_soft_session_reservation() {} - - if connection_data.connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - connection_count_changed |= connection_data.connections != previous_connection_count; - } - } - let state_changed = had_registration || !disconnected_users.is_empty(); - ( - state_changed, - connection_count_changed, - disconnected_users, - removed_streams, - expiry_entries, - preserved_updates, - promotions, - ) - }; - - for entry in expiry_entries { - self.enqueue_adaptive_expiry(entry).await; - } - - for stream_info in preserved_updates { - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); - } - - for username in &disconnected_users { - if !username.is_empty() { - debug_if_enabled!( - "Released connection for user {username} at {}", - sanitize_sensitive_info(&addr.to_string()) - ); - } - } - if connection_count_changed { - self.log_active_user().await; - } - if addr_removed { - for (username, action) in promotions { - self.emit_promotion_update(&username, action).await; - } - } - - ReleasedConnection { addr_removed, removed_streams, disconnected_users } - } - - pub async fn release_connection(&self, addr: &SocketAddr) -> ReleasedConnection { - let released = self.release_connection_inner(addr, true).await; - // divergence check after connection release - if released.addr_removed { - for username in &released.disconnected_users { - self.check_and_log_divergence_for_user(username).await; - } - } - released - } - - pub async fn release_connection_as_kicked(&self, addr: &SocketAddr) -> ReleasedConnection { - let released = self.release_connection_inner(addr, false).await; - // divergence check after connection release - if released.addr_removed { - for username in &released.disconnected_users { - self.check_and_log_divergence_for_user(username).await; - } - } - released - } - - pub fn update_config(&self, config: &Config) { - let log_active_user = config.log.as_ref().is_some_and(|l| l.log_active_user); - let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config); - self.grace_period_millis.store(grace_period_millis, Ordering::Relaxed); - self.grace_period_timeout_secs.store(grace_period_timeout_secs, Ordering::Relaxed); - self.adaptive_session_ttl_secs.store(get_adaptive_session_ttl_secs(config), Ordering::Relaxed); - self.log_active_user.store(log_active_user, Ordering::Relaxed); - } - - pub async fn user_connections(&self, username: &str) -> u32 { - if let Some(connection_data) = self.connections.read().await.by_key.get(username) { - return connection_data.connections; - } - 0 - } - - fn check_connection_admission_with_counts( - &self, - username: &str, - connection_data: &mut UserConnectionData, - counts: UserConnectionCounts, - ) -> ConnectionAdmission { - let selected_kind = - decide_connection_kind(counts, connection_data.max_connections, connection_data.soft_connections); - let effective_connections = counts.normal.saturating_add(u32::from(counts.soft)); - - if let Some(kind) = selected_kind { - // Reset grace only once the user is back below the hard limit. - if effective_connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - return ConnectionAdmission { permission: UserConnectionPermission::Allowed, kind: Some(kind) }; - } - - let now = get_current_timestamp(); - // Check if user already used a grace period - if connection_data.granted_grace { - if effective_connections >= connection_data.max_connections - && now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed) - { - // Grace timeout, still active, deny connection - debug!("User access denied, grace exhausted, too many connections: {username}"); - return ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: None }; - } - // Grace timeout expired, reset grace counters - if effective_connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - } - - debug!("User access denied, too many connections: {username}"); - ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: None } - } - - fn check_connection_admission( - &self, - username: &str, - connection_data: &mut UserConnectionData, - ) -> ConnectionAdmission { - self.check_connection_admission_with_counts( - username, - connection_data, - connection_data.effective_counts_for_admission(None), - ) - } - - pub async fn connection_admission( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - ) -> ConnectionAdmission { - if max_connections > 0 || soft_connections > 0 { - if let Some(connection_data) = self.connections.write().await.by_key.get_mut(username) { - connection_data.max_connections = max_connections; - connection_data.soft_connections = soft_connections; - return self.check_connection_admission(username, connection_data); - } - } - ConnectionAdmission { permission: UserConnectionPermission::Allowed, kind: Some(ConnectionKind::Normal) } - } - - pub async fn connection_permission( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - ) -> UserConnectionPermission { - self.connection_admission(username, max_connections, soft_connections).await.permission - } - - pub async fn connection_admission_for_session( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - session_token: &str, - ) -> ConnectionAdmission { - if max_connections == 0 && soft_connections == 0 { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: Some(ConnectionKind::Normal), - }; - } - - let mut connections = self.connections.write().await; - let Some(connection_data) = connections.by_key.get_mut(username) else { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: Some(ConnectionKind::Normal), - }; - }; - connection_data.max_connections = max_connections; - connection_data.soft_connections = soft_connections; - - let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) - else { - return self.check_connection_admission(username, connection_data); - }; - - if connection_data.sessions[session_index].lifecycle.is_counted() { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: connection_data.sessions[session_index].connection_kind.or(Some(ConnectionKind::Normal)), - }; - } - - self.check_connection_admission_with_counts( - username, - connection_data, - connection_data.effective_counts_for_admission(Some(session_token)), - ) - } - - pub async fn connection_permission_for_session( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - session_token: &str, - ) -> UserConnectionPermission { - self.connection_admission_for_session(username, max_connections, soft_connections, session_token) - .await - .permission - } - - pub async fn refresh_session_connection_kind_for_origin_policy( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - session_token: &str, - ) -> Option { - if max_connections == 0 && soft_connections == 0 { - return Some(ConnectionKind::Normal); - } - - let (connection_kind, promotions, divergence_snapshot) = { - let mut connections = self.connections.write().await; - let connection_data = connections.by_key.get_mut(username)?; - connection_data.max_connections = max_connections; - connection_data.soft_connections = soft_connections; - - let session_index = connection_data.sessions.iter().position(|session| session.token == session_token)?; - - let promotions = Self::promote_counted_soft_session_to_normal_if_available(connection_data, session_token); - let connection_kind = if connection_data.sessions[session_index].lifecycle.is_counted() - || Self::session_has_stream(connection_data, session_token) - { - Some(connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)) - } else { - let admission = self.check_connection_admission_with_counts( - username, - connection_data, - connection_data.effective_counts_for_admission(Some(session_token)), - ); - if admission.permission == UserConnectionPermission::Allowed { - if let Some(kind) = admission.kind { - Self::update_session_admission( - &mut connection_data.sessions[session_index], - admission.permission, - Some(kind), - ); - } - admission.kind - } else { - None - } - }; - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - - (connection_kind, promotions, divergence_snapshot) - }; - - self.log_divergence_snapshot(divergence_snapshot).await; - for action in promotions { - self.emit_promotion_update(username, action).await; - } - - connection_kind - } - - pub async fn get_eviction_candidates(&self, username: &str, _client_ip: &str) -> Vec { - let connections = self.connections.read().await; - let Some(connection_data) = connections.by_key.get(username) else { - return Vec::new(); - }; - let mut addr_counts = HashMap::new(); - for stream in &connection_data.streams { - // Preserved streams do not occupy a counted slot — exclude from addr counts. - // They are still valid eviction candidates (see filter below), but they don't - // consume connection capacity, so they don't contribute to the "singleton addr" logic. - let contributes_to_count = if stream.preserved { - false - } else if let Some(token) = stream.session_token.as_deref() { - connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()) - } else { - true // orphan streams without a session are counted - }; - if contributes_to_count { - addr_counts - .entry(stream.addr) - .and_modify(|count: &mut u8| *count = count.saturating_add(1)) - .or_insert(1_u8); - } - } - let candidates: Vec<_> = connection_data - .streams - .iter() - .filter(|stream| { - if let Some(token) = stream.session_token.as_deref() { - connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()) - || stream.preserved - } else { - true - } - }) - .filter(|stream| { - let addr_count = addr_counts.get(&stream.addr).copied().unwrap_or(0); - if stream.preserved { - // Preserved streams are always valid eviction candidates — they hold no counted - // slot. addr_count is 0 for preserved-only addresses, 1+ for addresses with - // counted competition. Either way they can be evicted. - true - } else { - // Non-preserved streams: only on singleton counted addresses. - // addr_count 0 = no counted streams at this address (shouldn't happen since - // non-preserved streams aren't preserved, but addr_count would be >= 1). - // addr_count 1 = single counted stream at address — candidate. - // addr_count > 1 = multiple counted streams — not a singleton, not candidate. - addr_count == 1 - } - }) - .map(|s| crate::EvictionCandidate { addr: s.addr, client_ip: s.client_ip.clone(), ts: s.ts }) - .collect(); - candidates - } - - pub async fn grant_grace(&self, username: &str) -> bool { - if self.grace_period_millis.load(Ordering::Relaxed) == 0 { - debug!("Grace grant denied, grace_period_millis is zero for {username}"); - return false; - } - let mut connections = self.connections.write().await; - if let Some(connection_data) = connections.by_key.get_mut(username) { - let now = get_current_timestamp(); - if connection_data.connections < connection_data.max_connections { - debug!( - "Grace grant denied for {username}, user not at connection limit ({}/{})", - connection_data.connections, connection_data.max_connections - ); - return false; - } - if connection_data.granted_grace - && connection_data.connections >= connection_data.max_connections - && now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed) - { - debug!("Grace grant denied, still within active grace timeout for {username}"); - return false; - } - connection_data.granted_grace = true; - connection_data.grace_ts = now; - debug!("Granted a grace period for user access: {username}"); - return true; - } - false - } - - pub async fn active_users_and_connections(&self) -> (usize, usize) { - self.gc(); - let user_connections = self.connections.read().await; - user_connections - .by_key - .values() - .filter_map(|c| { - let effective = c.connections as usize; - if effective > 0 { - Some(effective) - } else { - None - } - }) - .fold((0usize, 0usize), |(user_count, conn_count), effective| (user_count + 1, conn_count + effective)) - } - - pub async fn update_stream_detail( - &self, - addr: &SocketAddr, - video_type: CustomVideoStreamType, - ) -> Option { - let mut user_connections = self.connections.write().await; - let username = { - match user_connections.key_by_addr.get(addr) { - Some(registration) => registration.username.clone(), - None => return None, - } - }; - if let Some(connection_data) = user_connections.by_key.get_mut(&username) { - for stream in &mut connection_data.streams { - if &stream.addr == addr { - // IMPORTANT: `resolve_disconnect_reason` in connection_manager.rs parses - // `channel.title` back via `CustomVideoStreamType::from_str` to determine QoS - // disconnect reasons. If these values change, update that function too. - stream.provider = "tuliprox".intern(); - stream.channel.title = video_type.to_string().into(); - stream.channel.group = "".intern(); - stream.channel.technical = Some(Self::custom_stream_technical_info()); - return Some(stream.clone()); - } - } - } - None - } - - pub async fn add_connection(&self, addr: &SocketAddr) { - self.gc(); - let mut user_connections = self.connections.write().await; - user_connections - .key_by_addr - .entry(*addr) - .and_modify(|registration| registration.ts = current_time_secs()) - .or_insert_with(SocketRegistration::anonymous); - } - - #[allow(clippy::too_many_lines)] - pub async fn update_connection(&self, update: ActiveUserConnectionParams<'_>) -> Option { - let ActiveUserConnectionParams { - uid, - meter_uid, - username, - max_connections, - soft_connections, - connection_kind, - priority, - soft_priority: _, - fingerprint, - provider, - stream_channel, - user_agent, - session_token, - } = update; - let (stream_info, divergence_snapshot, connection_count_changed) = { - let mut user_connections = self.connections.write().await; - - let now = current_time_secs(); - if let Some(registration) = user_connections.key_by_addr.get_mut(&fingerprint.addr) { - registration.username = username.to_string(); - registration.ts = now; - } else { - user_connections - .key_by_addr - .insert(fingerprint.addr, SocketRegistration { username: username.to_string(), ts: now }); - } - - let tracked_socket_count = user_connections.key_by_addr.len(); - let connection_data = user_connections - .by_key - .entry(username.to_string()) - .or_insert_with(|| UserConnectionData::new(0, max_connections, soft_connections)); - connection_data.max_connections = max_connections; - connection_data.soft_connections = soft_connections; - let previous_connection_count = connection_data.connections; - - if let Some(token) = session_token { - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - session.ts = now; - remember_session_addr(session, fingerprint.addr); - Self::bump_session_transition_version(session); - } - } - - let user_agent_string = user_agent.to_string(); - let reserved_session_kind = session_token.and_then(|token| { - connection_data - .sessions - .iter() - .find(|session| session.token == token && session.lifecycle.is_counted()) - .map(|session| session.connection_kind.unwrap_or(connection_kind)) - }); - - let existing_stream_info = connection_data - .streams - .iter() - .position(|stream_info| match session_token { - Some(token) => { - stream_info.session_token.as_deref() == Some(token) - && Self::should_reuse_stream_for_session(stream_info, stream_channel) - } - None => stream_info.addr == fingerprint.addr && stream_info.session_token.is_none(), - }) - .map(|stream_idx| { - let session_started_at = session_token.and_then(|token| { - connection_data.sessions.iter().find(|s| s.token == token).map(|s| s.started_at) - }); - - let stream_info = &mut connection_data.streams[stream_idx]; - let client_ip = fingerprint.client_ip.clone(); - let preserve_started_at = stream_info.session_token.is_some() - && (stream_info.channel.item_type.is_live_adaptive() - || stream_channel.item_type.is_live_adaptive()); - let was_preserved = stream_info.preserved; - let old_session_id = stream_history_session_id(stream_info.ts, stream_info.uid); - stream_info.meter_uid = meter_uid; - stream_info.addr = fingerprint.addr; - stream_info.client_ip.clone_from(&client_ip); - stream_info.country_code = self.lookup_country(&client_ip); - stream_info.channel = stream_channel.clone(); - stream_info.provider = provider.clone(); - stream_info.user_agent.clone_from(&user_agent_string); - - if let Some(started_at) = session_started_at { - stream_info.started_at = started_at; - } - - if preserve_started_at { - let now = current_time_secs(); - if utc_day_from_secs(stream_info.ts) != utc_day_from_secs(now) { - stream_info.ts = now; - stream_info.previous_session_id = Some(old_session_id); - } - } else { - stream_info.ts = current_time_secs(); - } - - if let Some(token) = session_token { - stream_info.session_token = Some(token.to_string()); - } - if was_preserved { - stream_info.preserved = false; - } - connection_data.stream_normal_priorities.insert(stream_info.uid, priority); - let result = stream_info.clone(); - stream_info.previous_session_id = None; - (result, was_preserved) - }); - let (stream_info, divergence_snapshot) = if let Some((stream_info, was_preserved)) = existing_stream_info { - let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind); - if was_preserved { - connection_data.increment_kind(effective_connection_kind); - } - connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind); - connection_data.stream_normal_priorities.insert(stream_info.uid, priority); - if let Some(token) = session_token { - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - Self::mark_session_committed(session, effective_connection_kind); - } - } - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - (stream_info, divergence_snapshot) - } else { - let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind); - let country_code = self.lookup_country(&fingerprint.client_ip); - - let mut stream_info = StreamInfo::new(shared::model::StreamInfoParams { - uid, - meter_uid, - username, - addr: &fingerprint.addr, - client_ip: &fingerprint.client_ip, - provider, - stream_channel: stream_channel.clone(), - user_agent: user_agent_string, - country_code, - session_token, - }); - - if let Some(token) = session_token { - if let Some(session) = connection_data.sessions.iter().find(|s| s.token == token) { - stream_info.started_at = session.started_at; - } - } - - if reserved_session_kind.is_none() { - connection_data.increment_kind(effective_connection_kind); - } - connection_data.streams.push(stream_info.clone()); - connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind); - connection_data.stream_normal_priorities.insert(stream_info.uid, priority); - if let Some(token) = session_token { - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - Self::mark_session_committed(session, effective_connection_kind); - } - } - Self::log_connection_added(username, &fingerprint.addr, connection_data, tracked_socket_count); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - (stream_info, divergence_snapshot) - }; - let connection_count_changed = connection_data.connections != previous_connection_count; - (stream_info, divergence_snapshot, connection_count_changed) - }; - - self.log_divergence_snapshot(divergence_snapshot).await; - - if connection_count_changed { - self.log_active_user().await; - } - - Some(stream_info) - } - - fn is_log_user_enabled(&self) -> bool { self.log_active_user.load(Ordering::Relaxed) } - - fn build_preserved_stream_expiry( - &self, - username: &str, - stream: &StreamInfo, - sessions: &[UserSession], - ) -> Option { - let session_token = stream.session_token.as_deref()?; - // Catchup segment gaps can briefly lose the UserSession row; still preserve the panel - // row using the stream timestamp so Streams does not blink between archive chunks. - let session_ts = if let Some(session) = sessions.iter().find(|session| session.token == session_token) { - session.ts - } else if stream.channel.item_type == PlaylistItemType::Catchup || is_catchup_session_token(session_token) { - stream.ts - } else { - return None; - }; - - let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); - let expires_at = session_ts.saturating_add(ttl_secs); - Some(AdaptiveExpiryEntry { - expires_at, - username: username.to_string(), - session_token: session_token.to_string(), - uid: stream.uid, - }) - } - - async fn enqueue_adaptive_expiry(&self, entry: AdaptiveExpiryEntry) { - let key = AdaptiveExpiryKey { - username: entry.username.clone(), - session_token: entry.session_token.clone(), - uid: entry.uid, - }; - - let mut expiry_index = self.adaptive_expiry_index.lock().await; - expiry_index.insert(key, entry.expires_at); - drop(expiry_index); - - let mut queue = self.adaptive_expiry_queue.lock().await; - let wake_worker = queue.peek().is_none_or(|current| entry.expires_at < current.0.expires_at); - queue.push(Reverse(entry)); - if wake_worker { - self.adaptive_expiry_notify.notify_one(); - } - } - - fn new_user_session(params: &UserSessionParams<'_>) -> UserSession { - let now = current_time_secs(); - UserSession { - token: params.session_token.to_string(), - transition_version: 1, - 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], - ts: now, - started_at: now, - permission: params.connection_permission, - connection_kind: params.connection_kind, - lifecycle: PlaybackLifecycle::Prepared, - } - } - - fn promote_session_for_stream(connection_data: &mut UserConnectionData, stream: &StreamInfo) { - if let Some(token) = stream.session_token.as_deref() { - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - Self::mark_session_committed(session, ConnectionKind::Normal); - } - } - } - - fn collect_promotions_after_capacity_release(connection_data: &mut UserConnectionData) -> Vec { - let mut promotions = Vec::new(); - while let Some(action) = connection_data.try_promote_soft_stream() { - let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); - if let Some(stream) = promoted_stream.as_ref() { - Self::promote_session_for_stream(connection_data, stream); - } - promotions.push(action); - } - while connection_data.try_promote_soft_session_reservation() {} - promotions - } - - fn promote_counted_soft_session_to_normal_if_available( - connection_data: &mut UserConnectionData, - session_token: &str, - ) -> Vec { - if connection_data.max_connections > 0 && connection_data.counts.normal >= connection_data.max_connections { - return Vec::new(); - } - - let Some(session_index) = connection_data.sessions.iter().position(|session| { - session.token == session_token - && session.lifecycle.is_counted() - && session.connection_kind == Some(ConnectionKind::Soft) - }) else { - return Vec::new(); - }; - - if connection_data.counts.soft == 0 { - return Vec::new(); - } - - connection_data.counts.normal = connection_data.counts.normal.saturating_add(1); - connection_data.counts.soft = connection_data.counts.soft.saturating_sub(1); - connection_data.sessions[session_index].connection_kind = Some(ConnectionKind::Normal); - Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); - - let mut promotions = Vec::new(); - for stream in - connection_data.streams.iter().filter(|stream| stream.session_token.as_deref() == Some(session_token)) - { - if connection_data.stream_kinds.get(&stream.uid) != Some(&ConnectionKind::Soft) { - continue; - } - let new_priority = connection_data.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default(); - connection_data.stream_kinds.insert(stream.uid, ConnectionKind::Normal); - promotions.push(PromotionAction { addr: stream.addr, uid: stream.uid, new_priority }); - } - promotions - } - - fn bump_session_transition_version(session: &mut UserSession) -> u64 { - session.transition_version = session.transition_version.saturating_add(1); - session.transition_version - } - - fn mark_session_committed(session: &mut UserSession, kind: ConnectionKind) { - session.connection_kind = Some(kind); - session.lifecycle = PlaybackLifecycle::Active; - Self::bump_session_transition_version(session); - } - - fn update_session_admission( - session: &mut UserSession, - permission: UserConnectionPermission, - kind: Option, - ) { - session.permission = permission; - if let Some(kind) = kind { - session.connection_kind = Some(kind); - } - } - - fn clear_session_pending_with_permission( - session: &mut UserSession, - permission: UserConnectionPermission, - wake_source: PendingProviderWakeSource, - ) { - if let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle { - data.wake_source = Some(wake_source); - } - Self::bump_session_transition_version(session); - session.permission = permission; - } - - fn session_has_stream(connection_data: &UserConnectionData, session_token: &str) -> bool { - connection_data.streams.iter().any(|stream| stream.session_token.as_deref() == Some(session_token)) - } - - fn clear_session_counted_without_stream(connection_data: &mut UserConnectionData, session_token: &str) { - if Self::session_has_stream(connection_data, session_token) { - return; - } - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { - match session.lifecycle { - PlaybackLifecycle::Active => { - session.lifecycle = PlaybackLifecycle::Preserved; - } - // GraceActive without a stream: grace failed, expire the session. - // This can happen when the grace window times out while the client - // is still connecting but hasn't opened a stream yet. - PlaybackLifecycle::GraceActive => { - session.lifecycle = PlaybackLifecycle::Expired; - } - _ => {} - } - } - } - - fn clear_session_counted(connection_data: &mut UserConnectionData, session_token: &str) { - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { - match session.lifecycle { - PlaybackLifecycle::Active => { - session.lifecycle = PlaybackLifecycle::Preserved; - } - PlaybackLifecycle::GraceActive => { - // GraceActive without stream: the grace failed. The stream was already - // removed (this function is called after stream removal), so expire the session. - session.lifecycle = PlaybackLifecycle::Expired; - } - _ => {} - } - } - } - - fn release_expired_session_reservations(connection_data: &mut UserConnectionData, now: u64) { - let expired_counted = connection_data - .sessions - .iter() - .filter(|session| session.lifecycle.is_counted()) - .filter(|session| now.saturating_sub(session.ts) >= USER_CON_TTL) - .filter(|session| !Self::session_has_stream(connection_data, session.token.as_str())) - .map(|session| (session.token.clone(), session.connection_kind.unwrap_or(ConnectionKind::Normal))) - .collect::>(); - - for (_, kind) in &expired_counted { - connection_data.decrement_kind(*kind); - } - for (token, _) in expired_counted { - Self::clear_session_counted_without_stream(connection_data, &token); - } - while connection_data.try_promote_soft_session_reservation() {} - } - - pub async fn connection_admission_for_session_activation( - &self, - username: &str, - max_connections: u32, - soft_connections: u16, - session_token: &str, - ) -> ConnectionAdmission { - if max_connections == 0 && soft_connections == 0 { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: Some(ConnectionKind::Normal), - }; - } - - let mut connections = self.connections.write().await; - let Some(connection_data) = connections.by_key.get_mut(username) else { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: Some(ConnectionKind::Normal), - }; - }; - connection_data.max_connections = max_connections; - connection_data.soft_connections = soft_connections; - - let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) - else { - return self.check_connection_admission(username, connection_data); - }; - - // Existing counted session or any stream row for this token (including soft-preserved - // HLS/Catchup between segments): entitled to its slot. #807 switched this to - // active-only stream checks, so every LiveHls segment gap returned Exhausted - // and kick-evicted/terminated the same session (retry storm since v3.3.79). - if connection_data.sessions[session_index].lifecycle.is_counted() - || Self::session_has_stream(connection_data, session_token) - { - return ConnectionAdmission { - permission: UserConnectionPermission::Allowed, - kind: connection_data.sessions[session_index].connection_kind.or(Some(ConnectionKind::Normal)), - }; - } - - // Uncounted session with no stream row: run normal admission. - // Same-token soft-preserve is handled above via `session_has_stream` (3.3.78 semantics). - // Do not return Exhausted for own preserved rows — that forced self-eviction on HLS gaps. - let admission = self.check_connection_admission_with_counts( - username, - connection_data, - connection_data.effective_counts_for_admission(Some(session_token)), - ); - if admission.permission == UserConnectionPermission::Allowed { - let session = &mut connection_data.sessions[session_index]; - Self::update_session_admission(session, admission.permission, admission.kind); - } - admission - } - - pub async fn ensure_user_session_placeholder(&self, request: CreateUserSessionParams<'_>) -> u64 { - let CreateUserSessionParams { - user, - session_token, - virtual_id, - provider, - stream_url, - addr, - connection_permission, - connection_kind, - socket_bound, - } = request; - self.gc(); - - let username = user.username.clone(); - let mut user_connections = self.connections.write().await; - let connection_data = user_connections - .by_key - .entry(username.clone()) - .or_insert_with(|| UserConnectionData::new(0, user.max_connections, user.soft_connections)); - - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { - session.ts = current_time_secs(); - session.socket_bound = socket_bound; - remember_session_addr(session, *addr); - if session.connection_kind.is_none() { - session.connection_kind = connection_kind; - } - if session.permission == UserConnectionPermission::Exhausted { - Self::update_session_admission(session, connection_permission, None); - } - let version = Self::bump_session_transition_version(session); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - return version; - } - - let session = Self::new_user_session(&UserSessionParams { - session_token, - virtual_id, - provider, - stream_url, - addr, - connection_permission, - connection_kind, - socket_bound, - }); - let version = session.transition_version; - connection_data.add_session(session); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - version - } - - pub async fn release_unbound_session_reservation( - &self, - username: &str, - session_token: &str, - expected_transition_version: Option, - remove_session_if_unbound: bool, - ) { - let (connection_changed, user_removed, promotions, divergence_snapshot) = { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - - if Self::session_has_stream(connection_data, session_token) { - return; - } - - let Some(session_index) = - connection_data.sessions.iter().position(|session| session.token == session_token) - else { - return; - }; - - if expected_transition_version - .is_some_and(|expected| connection_data.sessions[session_index].transition_version != expected) - { - return; - } - - let mut connection_changed = false; - if connection_data.sessions[session_index].lifecycle.is_counted() { - let kind = connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal); - connection_data.decrement_kind(kind); - connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired; - connection_changed = true; - } - connection_data.sessions[session_index].transition_version = - connection_data.sessions[session_index].transition_version.saturating_add(1); - - if remove_session_if_unbound { - connection_data.sessions.swap_remove(session_index); - } - - if connection_data.connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - - let mut promotions = Vec::new(); - while let Some(action) = connection_data.try_promote_soft_stream() { - let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); - if let Some(stream) = promoted_stream.as_ref() { - Self::promote_session_for_stream(connection_data, stream); - } - promotions.push(action); - } - while connection_data.try_promote_soft_session_reservation() {} - - let user_removed = connection_data.connections == 0 - && connection_data.streams.is_empty() - && connection_data.sessions.is_empty(); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - - (connection_changed, user_removed, promotions, divergence_snapshot) - }; - - self.log_divergence_snapshot(divergence_snapshot).await; - - if user_removed { - let mut user_connections = self.connections.write().await; - user_connections.by_key.remove(username); - } - if connection_changed || user_removed { - self.log_active_user().await; - } - for action in promotions { - self.emit_promotion_update(username, action).await; - } - } - - pub async fn release_session_streams_and_counted_reservation(&self, username: &str, session_token: &str) -> bool { - let (connection_changed, user_removed, promotions, divergence_snapshot) = { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return false; - }; - - let counted_kind = connection_data - .sessions - .iter() - .find(|session| session.token == session_token && session.lifecycle.is_counted()) - .and_then(|session| session.connection_kind); - let (_removed_streams, mut connection_changed) = - connection_data.remove_streams_for_session_and_release_counted(session_token, counted_kind); - Self::clear_session_counted_without_stream(connection_data, session_token); - - if connection_data.connections < connection_data.max_connections { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - - let promotions = Self::collect_promotions_after_capacity_release(connection_data); - let user_removed = connection_data.connections == 0 - && connection_data.streams.is_empty() - && connection_data.sessions.is_empty(); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - connection_changed |= !promotions.is_empty(); - - (connection_changed, user_removed, promotions, divergence_snapshot) - }; - - self.log_divergence_snapshot(divergence_snapshot).await; - - if user_removed { - let mut user_connections = self.connections.write().await; - user_connections.by_key.remove(username); - } - if connection_changed || user_removed { - self.log_active_user().await; - } - for action in promotions { - self.emit_promotion_update(username, action).await; - } - connection_changed || user_removed - } - - pub async fn create_user_session(&self, request: CreateUserSessionParams<'_>) -> String { - let CreateUserSessionParams { - user, - session_token, - virtual_id, - provider, - stream_url, - addr, - connection_permission, - connection_kind, - socket_bound, - } = request; - self.gc(); - - let username = user.username.clone(); - let mut user_connections = self.connections.write().await; - let connection_data = user_connections.by_key.entry(username.clone()).or_insert_with(|| { - debug_if_enabled!("Creating first session for user {username} {}", sanitize_sensitive_info(stream_url)); - let mut data = UserConnectionData::new(0, user.max_connections, user.soft_connections); - let session = Self::new_user_session(&UserSessionParams { - session_token, - virtual_id, - provider, - stream_url, - addr, - connection_permission, - connection_kind, - socket_bound, - }); - data.add_session(session); - data - }); - - // If a session exists, update it - for session in &mut connection_data.sessions { - if session.token == session_token { - session.ts = current_time_secs(); - 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 - // when a new request arrives for the same session token - the request is either - // a reactivation (Activate) or a follow-up on a still-valid logical playback. - match session.lifecycle { - PlaybackLifecycle::Expired => { - session.lifecycle = PlaybackLifecycle::Prepared; - } - // PendingProvider: pending wait continues until explicitly resolved. - // Preserved: stays preserved until explicit reactivation via activation path. - // Prepared: placeholder session, no counted lease. - // Active: session is already in a valid counted state. - // All these keep their current state - session.refresh() alone does not advance it. - #[allow(clippy::match_same_arms)] - PlaybackLifecycle::PendingProvider { .. } - | PlaybackLifecycle::Preserved - | PlaybackLifecycle::Prepared - | PlaybackLifecycle::Active => {} - PlaybackLifecycle::GraceActive => { - // GraceActive refresh keeps the provisional state. Grace window is still - // running — refresh does not advance it. The grace task will resolve it. - } - } - Self::update_session_admission(session, connection_permission, connection_kind); - debug_if_enabled!( - "Using session for user {} with url: {}", - user.username, - sanitize_sensitive_info(stream_url) - ); - return session.token.clone(); - } - } - - // If no session exists, create one - debug_if_enabled!( - "Creating session for user {} with url: {}", - user.username, - sanitize_sensitive_info(stream_url) - ); - let session = Self::new_user_session(&UserSessionParams { - session_token, - virtual_id, - provider, - stream_url, - addr, - connection_permission, - connection_kind, - socket_bound, - }); - let token = session.token.clone(); - connection_data.add_session(session); - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - token - } - - pub async fn update_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) { - let now = current_time_secs(); - let mut user_connections = self.connections.write().await; - if let Some(connection_data) = user_connections.by_key.get_mut(username) { - let update_result = if let Some(session) = connection_data.sessions.iter_mut().find(|s| s.token == token) { - let previous_addr = session.addr; - remember_session_addr(session, *addr); - session.ts = now; - Self::bump_session_transition_version(session); - for stream in &mut connection_data.streams { - if stream.addr == previous_addr { - stream.addr = *addr; - stream.ts = now; - } - } - let prune_previous_registration = previous_addr != *addr - && !connection_data.has_session_addr(&previous_addr) - && !connection_data.streams.iter().any(|stream| stream.addr == previous_addr); - Some((previous_addr, prune_previous_registration)) - } else { - None - }; - - if let Some((previous_addr, prune_previous_registration)) = update_result { - if let Some(registration) = user_connections.key_by_addr.get_mut(addr) { - registration.ts = now; - registration.username = username.to_string(); - } else { - user_connections - .key_by_addr - .insert(*addr, SocketRegistration { username: username.to_string(), ts: now }); - } - if prune_previous_registration { - let can_remove_previous = user_connections - .key_by_addr - .get(&previous_addr) - .is_some_and(|registration| registration.username == username); - if can_remove_previous { - user_connections.key_by_addr.remove(&previous_addr); - } - } - debug_if_enabled!( - "Updated session {token} for {username} address {} -> {}", - sanitize_sensitive_info(&previous_addr.to_string()), - sanitize_sensitive_info(&addr.to_string()) - ); - } - } - } - - pub async fn clear_unbound_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) { - let now = current_time_secs(); - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - let addr_has_active_stream_for_session = connection_data - .streams - .iter() - .any(|stream| stream.session_token.as_deref() == Some(token) && stream.addr == *addr && !stream.preserved); - if addr_has_active_stream_for_session { - return; - } - - let cleared = if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) - { - let changed = clear_session_addr(session, addr); - if changed { - Self::bump_session_transition_version(session); - } - changed - } else { - false - }; - if !cleared { - let can_remove_registration = !connection_data.has_session_addr(addr) - && !connection_data.streams.iter().any(|stream| stream.addr == *addr); - if can_remove_registration { - let can_remove = user_connections - .key_by_addr - .get(addr) - .is_some_and(|registration| registration.username.is_empty() || registration.username == username); - if can_remove { - user_connections.key_by_addr.remove(addr); - } - } - return; - } - - let can_remove_registration = !connection_data.has_session_addr(addr) - && !connection_data.streams.iter().any(|stream| stream.addr == *addr); - if can_remove_registration { - let can_remove = user_connections - .key_by_addr - .get(addr) - .is_some_and(|registration| registration.username.is_empty() || registration.username == username); - if can_remove { - user_connections.key_by_addr.remove(addr); - } - } else if let Some(registration) = user_connections.key_by_addr.get_mut(addr) { - registration.ts = now; - } - } - - pub async fn mark_pending_provider( - &self, - username: &str, - token: &str, - reason_code: PendingProviderReason, - deadline: u64, - ) -> Option { - let mut user_connections = self.connections.write().await; - let connection_data = user_connections.by_key.get_mut(username)?; - let now = current_time_secs(); - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - let version = match session.lifecycle { - PlaybackLifecycle::PendingProvider { ref data } => data.version.saturating_add(1), - _ => 1, - }; - // Capture counted status BEFORE lifecycle transition to PendingProvider. - // is_counted() returns false for PendingProvider, so we must check first. - let kind = if session.lifecycle.is_counted() { - Some(session.connection_kind.unwrap_or(ConnectionKind::Normal)) - } else { - None - }; - session.ts = now; - Self::bump_session_transition_version(session); - Self::update_session_admission(session, UserConnectionPermission::GracePeriod, None); - session.lifecycle = PlaybackLifecycle::PendingProvider { - data: PendingProviderState { reason_code, created_at: now, deadline, version, wake_source: None }, - }; - if let Some(kind) = kind { - connection_data.decrement_kind(kind); - } - return Some(version); - } - None - } - - pub async fn activate_pending_provider( - &self, - username: &str, - token: &str, - expected_version: u64, - wake_source: PendingProviderWakeSource, - ) { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle else { - return; - }; - if data.version != expected_version { - return; - } - data.wake_source = Some(wake_source); - Self::bump_session_transition_version(session); - session.permission = UserConnectionPermission::Allowed; - session.lifecycle = PlaybackLifecycle::Active; - } - } - - /// Returns the current `transition_version` if the session is in `GraceActive` lifecycle. - /// Used by the grace task to confirm the session is still in `GraceActive` before committing. - pub async fn grace_active_version(&self, username: &str, token: &str) -> Option { - let connections = self.connections.read().await; - let connection_data = connections.by_key.get(username)?; - let session = connection_data.sessions.iter().find(|s| s.token == token)?; - if session.lifecycle == PlaybackLifecycle::GraceActive { - Some(session.transition_version) - } else { - None - } - } - - /// Marks a session as `GraceActive` — the session was granted immediate grace - /// (`GraceMode::Instant`) and is provisionally active. The session counts against - /// admission limits in this state. - /// - /// This corresponds to `Prepared -> GraceActive` in the playback state machine. - /// The session remains in `GraceActive` until either: - /// - `activate_grace_active` confirms it (grace window succeeded -> `GraceActive -> Active`) - /// - `expire_grace_active` expires it (grace window failed -> `GraceActive -> Expired`) - pub async fn mark_grace_active(&self, username: &str, token: &str) { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { - return; - }; - if connection_data.sessions[session_index].lifecycle == PlaybackLifecycle::GraceActive { - return; // already grace active - } - // Collect fields while only borrowing sessions. - let kind = connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal); - let needs_count = !connection_data.sessions[session_index].lifecycle.is_counted(); - let now = current_time_secs(); - // Now mutate. Use index access to avoid nested &mut borrows. - connection_data.sessions[session_index].ts = now; - Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); - if needs_count { - connection_data.increment_kind(kind); - } - connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::GraceActive; - } - - /// Activates a `GraceActive` session when the grace window resolves successfully. - /// - /// This corresponds to `GraceActive -> Active` in the playback state machine. - /// The session remains counted and the kind counts are already correct from - /// the `GraceActive` provisional state. - pub async fn activate_grace_active(&self, username: &str, token: &str, expected_version: u64) { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { - if session.transition_version != expected_version { - return; - } - if session.lifecycle != PlaybackLifecycle::GraceActive { - return; - } - Self::bump_session_transition_version(session); - session.lifecycle = PlaybackLifecycle::Active; - session.permission = UserConnectionPermission::Allowed; - } - } - - /// Expires a `GraceActive` session when the grace window fails. - /// - /// This corresponds to `GraceActive -> Expired` in the playback state machine. - /// Releases the provisional counted lease. - pub async fn expire_grace_active(&self, username: &str, token: &str, expected_version: u64) { - let (connection_changed, removed_count) = { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { - return; - }; - - if connection_data.sessions[session_index].transition_version != expected_version { - return; - } - if connection_data.sessions[session_index].lifecycle != PlaybackLifecycle::GraceActive { - return; - } - - // Release the provisional counted lease using index-based access - // to avoid nested mutable borrows with connection_data methods. - let mut connection_changed = false; - let mut counted_kind: Option = None; - if connection_data.sessions[session_index].lifecycle.is_counted() { - counted_kind = connection_data.sessions[session_index].connection_kind; - connection_changed = true; - } - if let Some(kind) = counted_kind { - connection_data.decrement_kind(kind); - } - - // Expire the session. Lifecycle change alone handles counted state (Expired is not counted). - connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired; - connection_data.sessions[session_index].permission = UserConnectionPermission::Exhausted; - Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); - - // Collect addresses for stream cleanup. - let mut addrs = Vec::new(); - let session_addr = connection_data.sessions[session_index].addr; - if !session_addr.ip().is_unspecified() { - addrs.push(session_addr); - } - for addr in &connection_data.sessions[session_index].active_addrs { - if *addr != session_addr && !addrs.contains(addr) { - addrs.push(*addr); - } - } - - // Remove all streams for these addresses (never preserve on expire). - let mut removed_count = 0; - for addr in &addrs { - if let Some(stream_idx) = - connection_data.streams.iter().position(|stream| stream.addr == *addr && !stream.preserved) - { - if let Some(kind) = connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) { - connection_data.decrement_kind(kind); - } - connection_data.stream_normal_priorities.remove(&connection_data.streams[stream_idx].uid); - connection_data.streams.swap_remove(stream_idx); - removed_count += 1; - } - } - - // Reset grace if no connections remain. - if connection_data.connections == 0 && connection_data.streams.is_empty() { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - - (connection_changed, removed_count) - }; - - if connection_changed { - self.log_active_user().await; - } - debug!("GraceActive expired for session {token} in {username}, released {removed_count} streams"); - } - - /// Terminates the session and all associated streams for a playback. - /// - /// This is the explicit `Terminate` path from the playback state machine: - /// - Removes all streams associated with this session token (never preserves) - /// - Releases the counted lease if held - /// - Sets lifecycle to `Expired` - /// - Clears pending-provider state - /// - /// Unlike `release_unbound_session_reservation`, this terminates regardless of - /// whether streams are currently active, and always removes associated streams. - pub async fn terminate_session(&self, username: &str, session_token: &str) { - let (connection_changed, removed_count, promotions) = { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - - let Some(session_index) = - connection_data.sessions.iter().position(|session| session.token == session_token) - else { - return; - }; - - let counted_kind = connection_data.sessions[session_index] - .lifecycle - .is_counted() - .then(|| connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)); - let (removed_count, connection_changed) = - connection_data.remove_streams_for_session_and_release_counted(session_token, counted_kind); - - // Expire and remove the session immediately. Unlike `release_unbound_session_reservation` - // which keeps the expired session for TTL-based GC cleanup, terminate_session explicitly - // removes the session from the list so `get_and_update_user_session` returns None. - connection_data.sessions.swap_remove(session_index); - - // Reset grace if no connections remain. - if connection_data.connections == 0 && connection_data.streams.is_empty() { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - - let promotions = Self::collect_promotions_after_capacity_release(connection_data); - - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - - (connection_changed, removed_count, promotions) - }; - - if connection_changed { - self.log_active_user().await; - } - for action in promotions { - self.emit_promotion_update(username, action).await; - } - debug!("Terminated session {session_token} for user {username}, released {removed_count} streams"); - } - - /// Terminates all sessions associated with a given socket address for a user. - /// - /// This is used when a connection is explicitly kicked — the session should be - /// expired and removed immediately rather than waiting for TTL-based GC cleanup. - /// - /// Removes all sessions whose `addr` or `active_addrs` contains `kick_addr`. - pub async fn terminate_sessions_for_addr(&self, username: &str, kick_addr: &SocketAddr) { - let (connection_changed, removed_count, promotions) = { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - - // Collect tokens of sessions associated with the kicked addr. - let tokens_to_remove: Vec = connection_data - .sessions - .iter() - .filter(|session| session.addr == *kick_addr || session.active_addrs.contains(kick_addr)) - .map(|session| session.token.clone()) - .collect(); - - if tokens_to_remove.is_empty() { - return; - } - - let mut removed_count = 0; - let mut connection_changed = false; - - for token in &tokens_to_remove { - let Some(session_index) = connection_data.sessions.iter().position(|s| s.token == *token) else { - continue; - }; - - let counted_kind = connection_data.sessions[session_index] - .lifecycle - .is_counted() - .then(|| connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)); - - let (_, session_connection_changed) = - connection_data.remove_streams_for_session_and_release_counted(token, counted_kind); - connection_changed |= session_connection_changed; - - // Expire and remove the session. - connection_data.sessions.swap_remove(session_index); - removed_count += 1; - } - - // Reset grace if no connections remain. - if connection_data.connections == 0 && connection_data.streams.is_empty() { - connection_data.granted_grace = false; - connection_data.grace_ts = 0; - } - - let promotions = Self::collect_promotions_after_capacity_release(connection_data); - - let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); - drop(user_connections); - self.log_divergence_snapshot(divergence_snapshot).await; - - (connection_changed, removed_count, promotions) - }; - - if connection_changed { - self.log_active_user().await; - } - for action in promotions { - self.emit_promotion_update(username, action).await; - } - debug!("Terminated {removed_count} sessions for user {username} at addr {kick_addr}"); - } - - pub async fn expire_pending_provider( - &self, - username: &str, - token: &str, - expected_version: u64, - wake_source: PendingProviderWakeSource, - ) { - let mut user_connections = self.connections.write().await; - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { - return; - }; - let pending_version = match &connection_data.sessions[session_index].lifecycle { - PlaybackLifecycle::PendingProvider { data } => data.version, - _ => return, - }; - if pending_version != expected_version { - return; - } - // Capture counted status BEFORE lifecycle changes. - // PendingProvider is not counted (is_counted() = false), so checking here - // captures whether there is a previously-counted lease to release. - let kind_to_release = if connection_data.sessions[session_index].lifecycle.is_counted() { - Some(connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)) - } else { - None - }; - let session = &mut connection_data.sessions[session_index]; - Self::clear_session_pending_with_permission(session, UserConnectionPermission::Exhausted, wake_source); - session.lifecycle = PlaybackLifecycle::Expired; - if let Some(kind) = kind_to_release { - connection_data.decrement_kind(kind); - } - } - - pub async fn adaptive_session_stream_cleanup_addrs( - &self, - username: &str, - session_token: &str, - current_addr: &SocketAddr, - ) -> Vec { - let connections = self.connections.read().await; - let Some(connection_data) = connections.by_key.get(username) else { - return Vec::new(); - }; - - let mut addrs = Vec::new(); - for stream in - connection_data.streams.iter().filter(|stream| stream.session_token.as_deref() == Some(session_token)) - { - if stream.addr != *current_addr && !addrs.contains(&stream.addr) { - addrs.push(stream.addr); - } - } - let current_addr_string = current_addr.to_string(); - let current_ip = strip_port(¤t_addr_string).to_string(); - if let Some(session) = connection_data.sessions.iter().find(|session| session.token == session_token) { - for addr in &session.active_addrs { - let addr_string = addr.to_string(); - let addr_ip = strip_port(&addr_string); - if *addr != *current_addr && addr_ip == current_ip && !addrs.contains(addr) { - addrs.push(*addr); - } - } - } - addrs - } - - pub fn active_socket_ttl_secs(&self) -> u64 { - let configured_ttl = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); - if configured_ttl == 0 { - DEFAULT_ACTIVE_SOCKET_TTL_SECS - } else { - configured_ttl - } - } - - pub async fn socket_expiry_deadline(&self, addr: &SocketAddr) -> Option { - let ttl_secs = self.active_socket_ttl_secs(); - let connections = self.connections.read().await; - let registration = connections.key_by_addr.get(addr)?; - if registration.username.is_empty() { - return None; - } - - Some(registration.ts.saturating_add(ttl_secs)) - } - - pub async fn touch_socket_activity(&self, addr: &SocketAddr) { - let now = current_time_secs(); - let mut user_connections = self.connections.write().await; - let Some(username) = user_connections.key_by_addr.get_mut(addr).and_then(|registration| { - if registration.username.is_empty() { - None - } else { - registration.ts = now; - Some(registration.username.clone()) - } - }) else { - return; - }; - - if let Some(connection_data) = user_connections.by_key.get_mut(&username) { - connection_data.ts = now; - } - } - - pub async fn touch_http_activity(&self, username: &str, token: &str, addr: &SocketAddr) { - let now = current_time_secs(); - let mut user_connections = self.connections.write().await; - - let registration = user_connections.key_by_addr.entry(*addr).or_insert_with(SocketRegistration::anonymous); - registration.username = username.to_string(); - registration.ts = now; - - let Some(connection_data) = user_connections.by_key.get_mut(username) else { - return; - }; - - connection_data.ts = now; - - for session in &mut connection_data.sessions { - if session.token == token { - // Lightweight HTTP activity (for example HLS manifest reloads) refreshes - // continuity metadata only. It must not become an active stream socket: - // otherwise a manifest or probe request can steal the visible stream addr, - // and the real segment socket later migrates to that stale addr instead of - // being released/preserved. - session.ts = now; - break; - } - } - } - - pub async fn get_and_update_user_session(&self, username: &str, token: &str) -> Option { - self.update_user_session(username, token).await - } - - /// Session for target-scoped `virtual_id` and request token (used to recover leaked relative DVR segment paths). - pub async fn find_latest_session_for_target_stream( - &self, - username: &str, - target_id: u16, - input_name: &str, - virtual_id: u32, - session_token: &str, - ) -> Option { - let user_connections = self.connections.read().await; - let connection_data = user_connections.by_key.get(username)?; - connection_data - .streams - .iter() - .any(|stream| { - stream.channel.target_id == target_id - && stream.channel.input_name.as_ref() == input_name - && stream.channel.virtual_id == virtual_id - && stream.session_token.as_deref() == Some(session_token) - }) - .then_some(())?; - - connection_data - .sessions - .iter() - .find(|session| session.token == session_token && session.virtual_id == virtual_id) - .cloned() - } - - pub async fn update_session_provider_headers( - &self, - username: &str, - token: &str, - provider_session_headers: &HashMap, - ) -> 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 { - let user_connections = self.connections.read().await; - let connection_data = user_connections.by_key.get(username)?; - let session = connection_data.sessions.iter().find(|session| session.token == token)?; - match &session.lifecycle { - PlaybackLifecycle::PendingProvider { data } => Some(data.version), - _ => None, - } - } - - async fn update_user_session(&self, username: &str, token: &str) -> Option { - let mut user_connections = self.connections.write().await; - - let connection_data = user_connections.by_key.get_mut(username)?; - let now = current_time_secs(); - - connection_data.ts = now; - - let session_index = connection_data.sessions.iter().position(|s| s.token == token)?; - - connection_data.sessions[session_index].ts = now; - - if connection_data.max_connections > 0 - && connection_data.sessions[session_index].permission == UserConnectionPermission::GracePeriod - && !matches!(connection_data.sessions[session_index].lifecycle, PlaybackLifecycle::PendingProvider { .. }) - { - let admission = self.check_connection_admission(username, connection_data); - connection_data.sessions[session_index].permission = admission.permission; - if admission.kind.is_some() { - connection_data.sessions[session_index].connection_kind = admission.kind; - } - } - - Some(connection_data.sessions[session_index].clone()) - } - - pub async fn active_streams(&self) -> Vec { - self.gc(); - let user_connections = self.connections.read().await; - let mut streams = Vec::new(); - for connection_data in user_connections.by_key.values() { - for stream in &connection_data.streams { - // Keep active_streams free of preserved rows — shared-HLS join detection and - // connection accounting must not see stale archive segment leases (v3.3.81). - if !stream.preserved { - streams.push(stream.clone()); - } - } - } - streams - } - - /// Streams for the `WebUI` / `StatusCheck` snapshot. - /// - /// Includes preserved Catchup/HLS/DASH session rows so the panel keeps showing archive - /// playback between short segment sockets. Do not use this for shared-HLS accounting. - pub async fn panel_streams(&self) -> Vec { - self.gc(); - let user_connections = self.connections.read().await; - let mut streams = Vec::new(); - for connection_data in user_connections.by_key.values() { - for stream in &connection_data.streams { - if !stream.preserved || Self::should_preserve_session_stream(stream) { - streams.push(stream.clone()); - } - } - } - streams - } - - fn log_connection_added( - username: &str, - addr: &SocketAddr, - connection_data: &UserConnectionData, - tracked_socket_count: usize, - ) { - if log::log_enabled!(log::Level::Debug) { - let active_for_user = connection_data.connections; - if connection_data.max_connections > 0 && active_for_user > connection_data.max_connections { - let recent_sockets = connection_data - .streams - .iter() - .rev() - .take(3) - .map(|stream| stream.addr.to_string()) - .collect::>() - .join(", "); - let recent_sockets = if recent_sockets.is_empty() { String::from("n/a") } else { recent_sockets }; - let unique_clients = - connection_data.streams.iter().map(|stream| &stream.client_ip).collect::>().len(); - debug!( - "User {username} exceeded configured max connections ({}/{}). Unique clients: {}, recent sockets [{}]", - active_for_user, - connection_data.max_connections, - unique_clients, - recent_sockets - ); - } else { - debug_if_enabled!( - "Added new connection for {username} at {} (active user connections={active_for_user}, tracked sockets={tracked_socket_count})", - sanitize_sensitive_info(&addr.to_string()) - ); - } - } - } - - pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool { - let connections = self.connections.read().await; - let now = current_time_secs(); - matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now) - } - - pub async fn recently_evicted_session_protected_addr(&self, session_token: &str) -> Option { - let connections = self.connections.read().await; - let now = current_time_secs(); - let protection = connections.recently_evicted_sessions.get(session_token)?; - if protection.expires_at > now { - return Some(protection.protected_addr); - } - - let username = connections.by_key.iter().find_map(|(username, connection_data)| { - connection_data.sessions.iter().any(|session| session.token == session_token).then_some(username.as_str()) - })?; - connections - .key_by_addr - .get(&protection.protected_addr) - .filter(|registration| registration.username == username) - .map(|_| protection.protected_addr) - } - - pub async fn recent_socket_reentry_protected_addr( - &self, - username: &str, - client_ip: &str, - virtual_id: VirtualId, - ) -> Option { - let connections = self.connections.read().await; - let now = current_time_secs(); - let key = create_socket_reentry_guard_key(username, client_ip, virtual_id); - let protection = connections.recent_socket_reentry_guards.get(&key)?; - if protection.expires_at > now { - return Some(protection.protected_addr); - } - - connections - .key_by_addr - .get(&protection.protected_addr) - .filter(|registration| registration.username == username) - .map(|_| protection.protected_addr) - } - - pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) { - let block_for_secs = blocked_secs.clamp(0, 86_400); // max 1 day; - if block_for_secs > 0 { - let mut connections = self.connections.write().await; - let now = current_time_secs(); - connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); - if let Some(username) = connections - .key_by_addr - .get(addr) - .map(|registration| registration.username.clone()) - .filter(|username| !username.is_empty()) - { - let expires_at = now + block_for_secs; - connections.kicked.insert(username, (expires_at, virtual_id)); - } - } - } - - pub async fn mark_recent_eviction_guard_for_addr( - &self, - addr: &SocketAddr, - protected_addr: SocketAddr, - ttl_secs: u64, - ) { - if ttl_secs == 0 { - return; - } - - let mut connections = self.connections.write().await; - let now = current_time_secs(); - connections.recently_evicted_sessions.retain(|_, protection| protection.expires_at > now); - connections.recent_socket_reentry_guards.retain(|_, protection| protection.expires_at > now); - - let Some(username) = connections - .key_by_addr - .get(addr) - .map(|registration| registration.username.clone()) - .filter(|username| !username.is_empty()) - else { - return; - }; - - let Some(connection_data) = connections.by_key.get(&username) else { - return; - }; - - let protection = RecentWinnerProtection { protected_addr, expires_at: now + ttl_secs }; - let mut session_tokens = Vec::new(); - let mut socket_guard_keys = Vec::new(); - - for stream in connection_data.streams.iter().filter(|stream| stream.addr == *addr) { - if uses_session_reentry_guard(stream) && stream.session_token.is_some() { - let Some(session_token) = stream.session_token.clone() else { - continue; - }; - session_tokens.push(session_token); - } else { - socket_guard_keys.push(create_socket_reentry_guard_key( - &username, - &stream.client_ip, - shared::model::VirtualId::new(stream.channel.virtual_id), - )); - } - } - - for session_token in session_tokens { - connections.recently_evicted_sessions.insert(session_token, protection); - } - for key in socket_guard_keys { - connections.recent_socket_reentry_guards.insert(key, protection); - } - } - - pub async fn get_username_for_addr(&self, addr: &SocketAddr) -> Option { - self.connections.read().await.key_by_addr.get(addr).map(|registration| registration.username.clone()) - } - - fn should_preserve_session_stream(stream: &StreamInfo) -> bool { - stream.session_token.is_some() && is_stable_session_stream(stream) - } - - fn is_preserved_stream_expired(&self, stream: &StreamInfo, sessions: &[UserSession], now: u64) -> bool { - if !stream.preserved || !Self::should_preserve_session_stream(stream) { - return false; - } - - let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); - let Some(session_token) = stream.session_token.as_deref() else { - return true; - }; - - let session_ts = - sessions.iter().find(|session| session.token == session_token).map_or(stream.ts, |session| session.ts); - - now.saturating_sub(session_ts) >= ttl_secs - } - - fn collect_divergence_snapshot(connection_data: &UserConnectionData, username: &str) -> Option { - log_enabled!(log::Level::Debug).then(|| Self::build_divergence_snapshot(connection_data, username)) - } - - fn build_divergence_snapshot(connection_data: &UserConnectionData, username: &str) -> DivergenceSnapshot { - let connections = connection_data.connections; - let counted_sessions = connection_data.sessions.iter().filter(|s| s.lifecycle.is_counted()).count(); - let streams_count = connection_data.streams.len(); - let mut kinds = Vec::new(); - - for session in &connection_data.sessions { - if !session.lifecycle.is_counted() { - continue; - } - if matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { ref data } if data.reason_code == PendingProviderReason::GraceHold) - { - continue; - } - let has_active_stream = connection_data - .streams - .iter() - .any(|s| s.session_token.as_deref() == Some(&session.token) && !s.preserved); - if !has_active_stream { - kinds.push(DivergenceKind::CountedSessionWithoutStream); - } - } - - for stream in &connection_data.streams { - if stream.preserved { - continue; - } - let Some(token) = stream.session_token.as_deref() else { - continue; - }; - let has_counted_session = - connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()); - if !has_counted_session { - kinds.push(DivergenceKind::StreamWithoutCountedSession); - } - } - - #[allow(clippy::cast_possible_truncation)] - let counted_sessions_u32 = counted_sessions as u32; - if connections != counted_sessions_u32 { - kinds.push(DivergenceKind::ConnectionCountMismatch { legacy: connections, counted: counted_sessions_u32 }); - } - - DivergenceSnapshot { username: username.to_string(), connections, counted_sessions, streams_count, kinds } - } - - async fn log_divergence_snapshot(&self, snapshot: Option) { - let Some(snapshot) = snapshot else { - return; - }; - let cooldown = Duration::from_secs(self.divergence_cooldown_secs); - for kind in &snapshot.kinds { - let key = divergence_key(&snapshot.username, kind); - let should_log = { - let mut cache = self.divergence_cache.lock().await; - if let Some(entry) = cache.get_mut(&key) { - if entry.last_logged.elapsed() >= cooldown { - entry.last_logged = Instant::now(); - entry.count_since_last_log = 0; - true - } else { - entry.count_since_last_log = entry.count_since_last_log.saturating_add(1); - false - } - } else { - cache.push(key, DivergenceEntry { last_logged: Instant::now(), count_since_last_log: 0 }); - true - } - }; - - if should_log { - debug!( - "ADMISSION DIVERGENCE user={} kind={kind:?} connections={} counted_sessions={} streams={}", - snapshot.username, snapshot.connections, snapshot.counted_sessions, snapshot.streams_count, - ); - } - } - } - - async fn check_and_log_divergence_for_user(&self, username: &str) { - let snapshot = { - let connections = self.connections.read().await; - let Some(data) = connections.by_key.get(username) else { - return; - }; - Self::collect_divergence_snapshot(data, username) - }; - self.log_divergence_snapshot(snapshot).await; - } - - async fn run_adaptive_expiry_worker(self: Arc) { - loop { - let next_expiry = { - let queue = self.adaptive_expiry_queue.lock().await; - queue.peek().map(|entry| entry.0.expires_at) - }; - - match next_expiry { - None => { - tokio::select! { - () = self.adaptive_expiry_notify.notified() => {} - () = self.adaptive_expiry_cancel.cancelled() => break, - } - } - Some(expires_at) => { - let now = current_time_secs(); - if expires_at <= now { - self.process_due_adaptive_expiry_entries(now).await; - continue; - } - - tokio::select! { - () = tokio::time::sleep(Duration::from_secs(expires_at.saturating_sub(now))) => {} - () = self.adaptive_expiry_notify.notified() => {} - () = self.adaptive_expiry_cancel.cancelled() => break, - } - } - } - } - } - - #[allow(clippy::too_many_lines)] - async fn process_due_adaptive_expiry_entries(&self, now: u64) { - let mut due_entries = Vec::new(); - { - let mut queue = self.adaptive_expiry_queue.lock().await; - while let Some(entry) = queue.peek() { - if entry.0.expires_at > now { - break; - } - if let Some(Reverse(entry)) = queue.pop() { - due_entries.push(entry); - } - } - } - - if due_entries.is_empty() { - return; - } - - let usernames_to_check: std::collections::HashSet<_> = due_entries.iter().map(|e| &e.username).collect(); - - let mut removed_addrs: Vec = Vec::new(); - let mut cleanup_events: Vec<(std::net::SocketAddr, Box)> = Vec::new(); - let mut replacement_entries: Vec = Vec::new(); - let mut promotions: Vec<(String, PromotionAction)> = Vec::new(); - { - let mut expiry_index = self.adaptive_expiry_index.lock().await; - let mut user_connections = self.connections.write().await; - for entry in &due_entries { - let key = AdaptiveExpiryKey { - username: entry.username.clone(), - session_token: entry.session_token.clone(), - uid: entry.uid, - }; - let Some(current_expires_at) = expiry_index.get(&key).copied() else { - continue; - }; - if current_expires_at != entry.expires_at { - continue; - } - - let mut remove_user = false; - if let Some(connection_data) = user_connections.by_key.get_mut(&entry.username) { - let stream_idx_opt = connection_data.streams.iter().position(|stream| { - stream.uid == entry.uid - && stream.preserved - && stream.session_token.as_deref() == Some(entry.session_token.as_str()) - }); - - if let Some(stream_idx) = stream_idx_opt { - let should_remove = self.is_preserved_stream_expired( - &connection_data.streams[stream_idx], - &connection_data.sessions, - now, - ); - - if should_remove { - let addr = connection_data.streams[stream_idx].addr; - let session_token = connection_data.streams[stream_idx].session_token.clone(); - if self.cleanup_tx.get().is_some() { - cleanup_events.push((addr, Box::new(connection_data.streams[stream_idx].clone()))); - } else { - removed_addrs.push(addr); - } - let removed_stream = connection_data.streams.swap_remove(stream_idx); - if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) { - connection_data.decrement_kind(kind); - } - connection_data.stream_normal_priorities.remove(&removed_stream.uid); - if let Some(action) = connection_data.try_promote_soft_stream() { - let promoted_stream = - connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); - if let Some(stream) = promoted_stream.as_ref() { - Self::promote_session_for_stream(connection_data, stream); - } - promotions.push((entry.username.clone(), action)); - } - if let Some(session_token) = session_token.as_deref() { - Self::clear_session_counted_without_stream(connection_data, session_token); - } - while connection_data.try_promote_soft_session_reservation() {} - expiry_index.remove(&key); - } else if let Some(replacement_entry) = self.build_preserved_stream_expiry( - &entry.username, - &connection_data.streams[stream_idx], - &connection_data.sessions, - ) { - if replacement_entry.expires_at != current_expires_at { - replacement_entries.push(replacement_entry); - } - } - } else { - expiry_index.remove(&key); - } - - remove_user = connection_data.connections == 0 - && connection_data.streams.is_empty() - && connection_data.sessions.is_empty(); - } else { - expiry_index.remove(&key); - } - - if remove_user { - user_connections.by_key.remove(&entry.username); - } - } - } // locks released here - - // divergence check after adaptive expiry processing - for username in usernames_to_check { - let snapshot = { - let connections = self.connections.read().await; - connections.by_key.get(username).and_then(|data| Self::collect_divergence_snapshot(data, username)) - }; - self.log_divergence_snapshot(snapshot).await; - } - - if let Some(tx) = self.cleanup_tx.get() { - for (addr, stream_info) in cleanup_events { - if tx.try_send(CleanupEvent::AdaptiveSessionExpired { stream_info }).is_err() { - self.dropped_cleanup_events.fetch_add(1, Ordering::Relaxed); - debug!("Cleanup channel unavailable, dropping adaptive session expiry"); - removed_addrs.push(addr); - } - } - } - - for entry in replacement_entries { - self.enqueue_adaptive_expiry(entry).await; - } - - for (username, action) in promotions { - self.emit_promotion_update(&username, action).await; - } - - let had_removals = !removed_addrs.is_empty(); - for addr in removed_addrs { - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Disconnected(addr))); - } - if had_removals { - self.log_active_user().await; - } - } - - fn gc(&self) { - if let Some(gc_ts) = &self.gc_ts { - let ts = gc_ts.load(Ordering::Acquire); - let now = current_time_secs(); - - if now.saturating_sub(ts) > USER_GC_TTL - && gc_ts.compare_exchange(ts, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() - { - if let Ok(mut user_connections) = self.connections.try_write() { - user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); - user_connections.recently_evicted_sessions.retain(|_, protection| protection.expires_at > now); - user_connections.recent_socket_reentry_guards.retain(|_, protection| protection.expires_at > now); - for connection_data in user_connections.by_key.values_mut() { - Self::release_expired_session_reservations(connection_data, now); - connection_data.sessions.retain(|s| now.saturating_sub(s.ts) < USER_CON_TTL); - } - user_connections.by_key.retain(|_k, v| { - v.connections > 0 || !v.streams.is_empty() || now.saturating_sub(v.ts) < USER_CON_TTL - }); - user_connections.key_by_addr.retain(|_, registration| { - !(registration.username.is_empty() && now.saturating_sub(registration.ts) >= ANON_SOCKET_TTL) - }); - } else { - // Lock contention: release the GC claim so a subsequent caller can retry immediately. - let _ = gc_ts.compare_exchange(now, ts, Ordering::AcqRel, Ordering::Relaxed); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::EventManager; - use arc_swap::ArcSwapOption; - use shared::{ - model::{PlaylistItemType, ProxyType, StreamChannel, StreamInfo, XtreamCluster}, - utils::Internable, - }; - use std::{borrow::Cow, collections::HashMap, sync::Arc}; - use tuliprox_core::model::{Config, Fingerprint, ProxyUserCredentials}; - - fn test_channel(virtual_id: u32) -> StreamChannel { - StreamChannel { - target_id: 1, - virtual_id, - provider_id: 1, - input_name: "input".intern(), - item_type: PlaylistItemType::Live, - cluster: XtreamCluster::Live, - group: "group".intern(), - title: "title".intern(), - url: "http://localhost/stream.ts".intern(), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - } - } - - fn test_adaptive_channel(virtual_id: u32) -> StreamChannel { - StreamChannel { - target_id: 1, - virtual_id, - provider_id: 1, - input_name: "input".intern(), - item_type: PlaylistItemType::LiveHls, - cluster: XtreamCluster::Live, - group: "group".intern(), - title: "title".intern(), - url: "http://localhost/stream.ts".intern(), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - } - } - - fn test_series_channel(virtual_id: u32) -> StreamChannel { - StreamChannel { - item_type: PlaylistItemType::Series, - cluster: XtreamCluster::Series, - url: "http://localhost/series/episode.mkv".intern(), - ..test_channel(virtual_id) - } - } - - #[tokio::test] - async fn target_scoped_session_lookup_does_not_use_same_virtual_id_from_other_target() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55499".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "target-scoped-user".to_string(); - let mut target_one = test_channel(42); - target_one.target_id = 1; - let mut target_two = test_channel(42); - target_two.target_id = 2; - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-target-one", - virtual_id: 42, - provider: "provider", - stream_url: "http://localhost/target-one.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-target-two", - virtual_id: 42, - provider: "provider", - stream_url: "http://localhost/target-two.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - { - let mut connections = manager.connections.write().await; - assert!(connections.by_key.contains_key(&user.username), "user connection data should exist"); - let Some(data) = connections.by_key.get_mut(&user.username) else { - return; - }; - data.streams.push(StreamInfo::new(shared::model::StreamInfoParams { - uid: 1, - meter_uid: 1, - username: &user.username, - addr: &addr, - client_ip: "127.0.0.1", - provider: "provider".intern(), - stream_channel: target_one, - user_agent: "ua".to_string(), - country_code: None, - session_token: Some("tok-target-one"), - })); - data.streams.push(StreamInfo::new(shared::model::StreamInfoParams { - uid: 2, - meter_uid: 2, - username: &user.username, - addr: &addr, - client_ip: "127.0.0.1", - provider: "provider".intern(), - stream_channel: target_two, - user_agent: "ua".to_string(), - country_code: None, - session_token: Some("tok-target-two"), - })); - } - - let session = - manager.find_latest_session_for_target_stream(&user.username, 2, "input", 42, "tok-target-two").await; - assert!(session.is_some(), "target-scoped session should resolve"); - let Some(session) = session else { - return; - }; - assert_eq!(session.token, "tok-target-two"); - assert!(manager - .find_latest_session_for_target_stream(&user.username, 3, "input", 42, "tok-target-two") - .await - .is_none()); - assert!(manager - .find_latest_session_for_target_stream(&user.username, 1, "input", 42, "tok-target-two") - .await - .is_none()); - } - - /// Session refresh normalizes Expired -> Prepared. - /// When a new request arrives on an expired session, the lifecycle should be - /// reset to Prepared so that full activation evaluation happens. - #[tokio::test] - async fn create_user_session_normalizes_expired_lifecycle() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55400".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "user-lifecycle-refresh".to_string(); - - // Create a session in Expired state directly via session manipulation - { - let mut connections = manager.connections.write().await; - let data = - connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - data.add_session(UserSession { - token: "tok-refresh-expired".to_string(), - transition_version: 1, - 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], - ts: current_time_secs(), - started_at: current_time_secs(), - permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - lifecycle: PlaybackLifecycle::Expired, - }); - } - - // Refresh the session via create_user_session - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-refresh-expired", - virtual_id: 7001, - 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 sessions = manager.connections.read().await; - let data = sessions.by_key.get(&user.username).expect("user data should exist"); - let session = data.sessions.iter().find(|s| s.token == "tok-refresh-expired").expect("session"); - assert_eq!( - session.lifecycle, - PlaybackLifecycle::Prepared, - "Expired session should normalize to Prepared on refresh" - ); - } - - /// Session refresh does NOT normalize `PendingProvider`. - /// A `PendingProvider` session must not be reset — pending state must continue - /// until explicitly resolved via `activate_pending_provider` or `expire_pending_provider`. - #[tokio::test] - async fn create_user_session_does_not_normalize_pending_provider_lifecycle() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55401".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "user-pending-lifecycle".to_string(); - - // Create a session in PendingProvider state - { - let mut connections = manager.connections.write().await; - let data = - connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - data.add_session(UserSession { - token: "tok-refresh-pending".to_string(), - transition_version: 1, - 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], - ts: current_time_secs(), - started_at: current_time_secs(), - permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - lifecycle: PlaybackLifecycle::PendingProvider { - data: PendingProviderState { - reason_code: PendingProviderReason::GraceHold, - created_at: current_time_secs(), - deadline: current_time_secs() + 30, - version: 1, - wake_source: None, - }, - }, - }); - } - - // Refresh the session via create_user_session - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-refresh-pending", - virtual_id: 7002, - 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 sessions = manager.connections.read().await; - let data = sessions.by_key.get(&user.username).expect("user data should exist"); - let session = data.sessions.iter().find(|s| s.token == "tok-refresh-pending").expect("session"); - assert!( - matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }), - "PendingProvider session should NOT be normalized on refresh - pending wait must continue" - ); - } - - #[tokio::test] - async fn update_session_provider_headers_updates_existing_session_and_timestamp() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::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::::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::::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() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55410".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "user-terminate".to_string(); - user.max_connections = 2; - - let token = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-test", - virtual_id: 8001, - provider: "provider-terminate", - stream_url: "http://localhost/test.ts", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Verify session exists. - let before = manager.get_and_update_user_session(&user.username, &token).await; - assert!(before.is_some(), "session should exist before terminate"); - assert_eq!(before.as_ref().unwrap().lifecycle, PlaybackLifecycle::Prepared); - - // Terminate the session. - manager.terminate_session(&user.username, &token).await; - - // Session should be gone. - let after = manager.get_and_update_user_session(&user.username, &token).await; - assert!(after.is_none(), "session should be removed after terminate"); - } - - /// `terminate_session` releases counted lease. - #[tokio::test] - async fn terminate_session_releases_counted_lease() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55411".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "user-terminate-counted".to_string(); - user.max_connections = 2; - - let token = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-counted", - virtual_id: 8002, - provider: "provider-terminate-counted", - stream_url: "http://localhost/test.ts", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Mark the session as counted and active (simulating post-admission state). - { - let mut connections = manager.connections.write().await; - let data = connections.by_key.get_mut(&user.username).unwrap(); - let session = data.sessions.iter_mut().find(|s| s.token == token).unwrap(); - // Simulate counted state by setting lifecycle to Active. - session.lifecycle = PlaybackLifecycle::Active; - data.increment_kind(ConnectionKind::Normal); - } - - // Verify counted before terminate. - { - let before = manager.get_and_update_user_session(&user.username, &token).await.unwrap(); - assert!(before.lifecycle.is_counted(), "session should be counted before terminate"); - } - - // Terminate. - manager.terminate_session(&user.username, &token).await; - - // Session should be gone. - let after = manager.get_and_update_user_session(&user.username, &token).await; - assert!(after.is_none(), "session should be removed after terminate"); - } - - #[tokio::test] - async fn terminate_session_removes_preserved_adaptive_stream() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55412".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-terminate-preserved".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-terminate-preserved".to_string(); - user.max_connections = 1; - - let token = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-preserved", - virtual_id: 8003, - provider: "provider-terminate-preserved", - stream_url: "http://localhost/test.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 8003, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-terminate-preserved".intern(), - stream_channel: &test_adaptive_channel(8003), - user_agent: Cow::Borrowed("ua"), - session_token: Some(&token), - }) - .await - .expect("adaptive stream should be registered"); - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty(), "adaptive stream should be preserved first"); - - manager.terminate_session(&user.username, &token).await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user data should remain inspectable"); - assert!(connection_data.streams.is_empty(), "terminating a session must remove its preserved adaptive stream"); - assert!(connection_data.sessions.iter().all(|session| session.token != token)); - } - - #[allow(clippy::too_many_lines)] - #[tokio::test] - async fn terminate_session_promotes_soft_stream_after_releasing_capacity() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let normal_addr: SocketAddr = "127.0.0.1:55413".parse().unwrap(); - let soft_addr: SocketAddr = "127.0.0.1:55414".parse().unwrap(); - let soft_addr_two: SocketAddr = "127.0.0.1:55415".parse().unwrap(); - let normal_fp = Fingerprint::new("fp-terminate-normal".to_string(), "127.0.0.1".to_string(), normal_addr); - let soft_fp = Fingerprint::new("fp-terminate-soft".to_string(), "127.0.0.1".to_string(), soft_addr); - let soft_fp_two = Fingerprint::new("fp-terminate-soft-2".to_string(), "127.0.0.1".to_string(), soft_addr_two); - let mut user = ProxyUserCredentials::default(); - user.username = "user-terminate-promote".to_string(); - user.max_connections = 1; - user.soft_connections = 2; - - manager.add_connection(&normal_addr).await; - manager.add_connection(&soft_addr).await; - manager.add_connection(&soft_addr_two).await; - - let normal_token = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-normal", - virtual_id: 8101, - provider: "provider-normal", - stream_url: "http://localhost/normal.ts", - addr: &normal_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - let soft_token = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-soft", - virtual_id: 8102, - provider: "provider-soft", - stream_url: "http://localhost/soft.ts", - addr: &soft_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Soft), - socket_bound: false, - }) - .await; - let soft_token_two = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-terminate-soft-2", - virtual_id: 8103, - provider: "provider-soft-2", - stream_url: "http://localhost/soft-2.ts", - addr: &soft_addr_two, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Soft), - socket_bound: false, - }) - .await; - - manager - .update_connection(ActiveUserConnectionParams { - uid: 8101, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &normal_fp, - provider: "provider-normal".intern(), - stream_channel: &test_channel(8101), - user_agent: Cow::Borrowed("ua-normal"), - session_token: Some(&normal_token), - }) - .await - .expect("normal stream should be registered"); - manager - .update_connection(ActiveUserConnectionParams { - uid: 8102, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Soft, - priority: -5, - soft_priority: 9, - fingerprint: &soft_fp, - provider: "provider-soft".intern(), - stream_channel: &test_channel(8102), - user_agent: Cow::Borrowed("ua-soft"), - session_token: Some(&soft_token), - }) - .await - .expect("soft stream should be registered"); - manager - .update_connection(ActiveUserConnectionParams { - uid: 8103, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Soft, - priority: -3, - soft_priority: 9, - fingerprint: &soft_fp_two, - provider: "provider-soft-2".intern(), - stream_channel: &test_channel(8103), - user_agent: Cow::Borrowed("ua-soft-2"), - session_token: Some(&soft_token_two), - }) - .await - .expect("second soft stream should be registered"); - - { - let mut connections = manager.connections.write().await; - let connection_data = connections.by_key.get_mut(&user.username).expect("user data should exist"); - connection_data.soft_connections = 1; - } - - manager.terminate_session(&user.username, &normal_token).await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user data should remain inspectable"); - assert_eq!(connection_data.counts.normal, 1); - assert_eq!(connection_data.counts.soft, 1); - let promoted_uid = [8102_u32, 8103_u32] - .into_iter() - .find(|uid| connection_data.stream_kinds.get(uid) == Some(&ConnectionKind::Normal)) - .expect("one soft stream should be promoted to normal"); - let promoted_token = if promoted_uid == 8102 { soft_token.as_str() } else { soft_token_two.as_str() }; - let promoted_session = connection_data - .sessions - .iter() - .find(|session| session.token == promoted_token) - .expect("promoted soft session should remain"); - assert_eq!(promoted_session.connection_kind, Some(ConnectionKind::Normal)); - assert!(matches!(promoted_session.lifecycle, PlaybackLifecycle::Active)); - } - - #[tokio::test] - async fn test_multi_session_same_addr_counts_and_releases_individually() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55001".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key".to_string(), "127.0.0.1".to_string(), addr); - let username = "user1"; - - manager.add_connection(&addr).await; - - let first = manager - .update_connection(ActiveUserConnectionParams { - uid: 1, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(1001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-1"), - }) - .await; - assert!(first.is_some()); - assert_eq!(manager.user_connections(username).await, 1); - assert_eq!(manager.connection_permission(username, 1, 0).await, UserConnectionPermission::Exhausted); - - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 2, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-b".intern(), - stream_channel: &test_channel(1002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-2"), - }) - .await; - assert!(second.is_some()); - assert_eq!(manager.user_connections(username).await, 2); - - assert!(manager.release_stream(&addr).await.is_some()); - assert_eq!(manager.user_connections(username).await, 1); - - assert!(manager.release_stream(&addr).await.is_some()); - assert_eq!(manager.user_connections(username).await, 0); - } - - #[tokio::test] - async fn mark_pending_provider_tracks_metadata_on_session() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55021".parse().unwrap_or_else(|_| unreachable!()); - let mut user = ProxyUserCredentials::default(); - user.username = "pending-user".to_string(); - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending", - virtual_id: 1001, - provider: "provider-a", - stream_url: "http://provider/live/1001.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let _ = manager - .mark_pending_provider(&user.username, "tok-pending", PendingProviderReason::GraceHold, 12_345) - .await; - - let session = - manager.get_and_update_user_session(&user.username, "tok-pending").await.expect("session should exist"); - let PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else { - panic!("pending provider should be tracked") - }; - assert!(matches!(pending.reason_code, PendingProviderReason::GraceHold)); - assert_eq!(pending.deadline, 12_345); - assert!(pending.created_at > 0); - assert_eq!(pending.version, 1); - assert!(pending.wake_source.is_none()); - } - - #[tokio::test] - async fn activate_pending_provider_clears_pending_metadata() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55022".parse().unwrap_or_else(|_| unreachable!()); - let fingerprint = Fingerprint::new("fp-pending".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = "pending-activate".to_string(); - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending-activate", - virtual_id: 1002, - provider: "provider-a", - stream_url: "http://provider/live/1002.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - let _ = manager - .mark_pending_provider( - &user.username, - "tok-pending-activate", - PendingProviderReason::GraceHold, - current_time_secs().saturating_add(30), - ) - .await; - - let _ = manager - .update_connection(ActiveUserConnectionParams { - uid: 12, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(1002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-pending-activate"), - }) - .await; - manager - .activate_pending_provider(&user.username, "tok-pending-activate", 1, PendingProviderWakeSource::Activated) - .await; - - let session = manager - .get_and_update_user_session(&user.username, "tok-pending-activate") - .await - .expect("session should exist"); - assert!(session.lifecycle.is_counted()); - assert!( - !matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }), - "explicit pending resolution must clear pending provider state" - ); - } - - #[tokio::test] - async fn activate_pending_provider_ignores_stale_version_after_replacement() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap_or_else(|_| unreachable!()); - let mut user = ProxyUserCredentials::default(); - user.username = "pending-stale".to_string(); - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending-stale", - virtual_id: 1003, - provider: "provider-a", - stream_url: "http://provider/live/1003.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let first_version = manager - .mark_pending_provider(&user.username, "tok-pending-stale", PendingProviderReason::GraceHold, 5_000) - .await - .expect("first pending version should be created"); - let second_version = manager - .mark_pending_provider(&user.username, "tok-pending-stale", PendingProviderReason::GraceHold, 6_000) - .await - .expect("second pending version should replace the first"); - assert!(second_version > first_version); - - manager - .activate_pending_provider( - &user.username, - "tok-pending-stale", - first_version, - PendingProviderWakeSource::CapacityNotify, - ) - .await; - - let session = manager - .get_and_update_user_session(&user.username, "tok-pending-stale") - .await - .expect("session should still exist"); - let PlaybackLifecycle::PendingProvider { data: pending_data } = &session.lifecycle else { - panic!("session should still be in PendingProvider after stale wakeup") - }; - assert_eq!(pending_data.version, second_version); - assert!(pending_data.wake_source.is_none()); - assert_eq!(session.permission, UserConnectionPermission::GracePeriod); - } - - #[tokio::test] - async fn expire_pending_provider_marks_session_exhausted() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap_or_else(|_| unreachable!()); - let mut user = ProxyUserCredentials::default(); - user.username = "pending-expire".to_string(); - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending-expire", - virtual_id: 1004, - provider: "provider-a", - stream_url: "http://provider/live/1004.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let version = manager - .mark_pending_provider(&user.username, "tok-pending-expire", PendingProviderReason::GraceHold, 6_000) - .await - .expect("pending version should be created"); - - manager - .expire_pending_provider(&user.username, "tok-pending-expire", version, PendingProviderWakeSource::Timeout) - .await; - - let session = manager - .get_and_update_user_session(&user.username, "tok-pending-expire") - .await - .expect("session should still exist"); - assert_eq!(session.permission, UserConnectionPermission::Exhausted); - assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. })); - assert!(!session.lifecycle.is_counted()); - } - - #[tokio::test] - async fn expire_pending_provider_releases_counted_slot_for_pending_session() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55025".parse().unwrap_or_else(|_| unreachable!()); - let mut user = ProxyUserCredentials::default(); - user.username = "pending-expire-counted".to_string(); - user.max_connections = 1; - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending-expire-counted", - virtual_id: 1005, - provider: "provider-a", - stream_url: "http://provider/live/1005.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - { - let mut connections = manager.connections.write().await; - let connection_data = - connections.by_key.get_mut(&user.username).expect("session should have created connection data"); - connection_data.increment_kind(ConnectionKind::Normal); - let session = connection_data - .sessions - .iter_mut() - .find(|session| session.token == "tok-pending-expire-counted") - .expect("session should exist"); - // Simulate a previously-counted session transitioning to PendingProvider. - // Set lifecycle to Active (is_counted() = true). The kind count is already - // incremented above via connection_data.increment_kind(). - session.lifecycle = PlaybackLifecycle::Active; - } - - assert_eq!(manager.user_connections(&user.username).await, 1); - - let version = manager - .mark_pending_provider( - &user.username, - "tok-pending-expire-counted", - PendingProviderReason::GraceHold, - 6_500, - ) - .await - .expect("pending version should be created"); - - manager - .expire_pending_provider( - &user.username, - "tok-pending-expire-counted", - version, - PendingProviderWakeSource::Timeout, - ) - .await; - - let session = manager - .get_and_update_user_session(&user.username, "tok-pending-expire-counted") - .await - .expect("session should still exist"); - assert_eq!(session.permission, UserConnectionPermission::Exhausted); - assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. })); - assert!(!session.lifecycle.is_counted()); - assert_eq!(manager.user_connections(&user.username).await, 0); - } - - /// `terminate_sessions_for_addr` expires all sessions at a given addr and releases counted leases. - #[tokio::test] - async fn terminate_sessions_for_addr_expires_all_sessions_at_addr() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr_kick: SocketAddr = "127.0.0.1:55420".parse().unwrap(); - let addr_keep: SocketAddr = "127.0.0.1:55421".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "user-kick-addr".to_string(); - user.max_connections = 4; - - // Create session at kicked addr. - let tok_kick = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-kick", - virtual_id: 1, - provider: "provider-a", - stream_url: "http://provider/live/1.m3u8", - addr: &addr_kick, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Create session at kept addr. - let tok_keep = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-keep", - virtual_id: 2, - provider: "provider-b", - stream_url: "http://provider/live/2.m3u8", - addr: &addr_keep, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Mark both sessions as counted and active. - { - let mut connections = manager.connections.write().await; - let data = connections.by_key.get_mut(&user.username).unwrap(); - for session in &mut data.sessions { - // Simulate counted state by setting lifecycle to Active. - session.lifecycle = PlaybackLifecycle::Active; - } - data.increment_kind(ConnectionKind::Normal); - data.increment_kind(ConnectionKind::Normal); - } - - assert_eq!(manager.user_connections(&user.username).await, 2); - - // Kick the addr — should terminate only the sessions at that addr. - manager.terminate_sessions_for_addr(&user.username, &addr_kick).await; - - // Session at kicked addr should be gone. - assert!( - manager.get_and_update_user_session(&user.username, &tok_kick).await.is_none(), - "kicked session should be removed" - ); - - // Session at kept addr should remain. - let kept = manager - .get_and_update_user_session(&user.username, &tok_keep) - .await - .expect("kept session should still exist"); - assert_eq!(kept.token, tok_keep); - assert_eq!(kept.addr, addr_keep); - - // Connection count should drop by 1. - assert_eq!(manager.user_connections(&user.username).await, 1); - } - - #[tokio::test] - async fn test_grant_grace_succeeds_at_and_above_limit_without_prior_grace() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let at_limit_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap(); - let at_limit_fingerprint = Fingerprint::new("fp-limit".to_string(), "127.0.0.1".to_string(), at_limit_addr); - let over_limit_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap(); - let over_limit_fingerprint = Fingerprint::new("fp-over".to_string(), "127.0.0.1".to_string(), over_limit_addr); - - manager.add_connection(&at_limit_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 10, - meter_uid: 0, - username: "at-limit", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &at_limit_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(1010), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-limit"), - }) - .await; - - assert!(manager.grant_grace("at-limit").await); - - manager.add_connection(&over_limit_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 11, - meter_uid: 0, - username: "over-limit", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &over_limit_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(1011), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-over-1"), - }) - .await; - manager.add_connection(&"127.0.0.1:55013".parse().unwrap()).await; - let second_fingerprint = - Fingerprint::new("fp-over-2".to_string(), "127.0.0.1".to_string(), "127.0.0.1:55013".parse().unwrap()); - manager - .update_connection(ActiveUserConnectionParams { - uid: 12, - meter_uid: 0, - username: "over-limit", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &second_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(1012), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-over-2"), - }) - .await; - - assert!(manager.grant_grace("over-limit").await); - } - - fn test_user_credentials(username: &str, max_connections: u32, soft_connections: u16) -> ProxyUserCredentials { - ProxyUserCredentials { - username: username.to_string(), - password: "test".to_string(), - token: None, - proxy: ProxyType::default(), - server: None, - epg_timeshift: None, - epg_request_timeshift: None, - created_at: None, - exp_date: None, - max_connections, - status: None, - output_clusters: shared::model::ClusterFlags::all(), - ui_enabled: true, - comment: None, - priority: 0, - soft_connections, - soft_priority: 0, - t_is_api_user: false, - network_access: None, - plan: None, - filter: None, - raw_output_clusters: None, - raw_max_connections: 0, - raw_soft_connections: 0, - raw_proxy: Some(ProxyType::default()), - t_filter: None, - t_has_unresolved_plan: false, - t_has_invalid_filter: false, - } - } - - fn record_owned_slot(counts: &mut UserConnectionCounts, kind: ConnectionKind) { - match kind { - ConnectionKind::Normal => counts.normal += 1, - ConnectionKind::Soft => counts.soft += 1, - } - } - - fn assert_connection_ownership_invariants(connection_data: &UserConnectionData) { - let mut owned_slots = UserConnectionCounts::default(); - - // A counted session owns one logical slot. Active streams tied to that - // session validate its kind below, but do not add another slot. - for session in connection_data.sessions.iter().filter(|session| session.lifecycle.is_counted()) { - record_owned_slot(&mut owned_slots, session.connection_kind.unwrap_or(ConnectionKind::Normal)); - } - - for stream in &connection_data.streams { - let stream_kind = connection_data.stream_kinds.get(&stream.uid); - if stream.preserved { - assert!(stream_kind.is_none(), "preserved stream {} must not own a real connection slot", stream.uid); - continue; - } - - let stream_kind = stream_kind.expect("every active stream must have a connection kind"); - let counted_session = stream.session_token.as_deref().and_then(|session_token| { - connection_data - .sessions - .iter() - .find(|session| session.token == session_token && session.lifecycle.is_counted()) - }); - if let Some(session) = counted_session { - assert_eq!( - *stream_kind, - session.connection_kind.unwrap_or(ConnectionKind::Normal), - "a counted session and its active stream must use the same slot kind" - ); - } else { - record_owned_slot(&mut owned_slots, *stream_kind); - } - } - - for uid in connection_data.stream_kinds.keys() { - assert!( - connection_data.streams.iter().any(|stream| stream.uid == *uid && !stream.preserved), - "stream kind for uid {uid} must belong to an active stream" - ); - } - - assert_eq!(connection_data.counts.normal, owned_slots.normal, "normal slots must match their owners"); - assert_eq!(connection_data.counts.soft, owned_slots.soft, "soft slots must match their owners"); - assert_eq!( - connection_data.connections, - connection_data.counts.normal + u32::from(connection_data.counts.soft), - "aggregate connection count must equal the normal and soft counters" - ); - } - - fn assert_no_real_connection_slots(connection_data: &UserConnectionData) { - assert_eq!(connection_data.connections, 0); - assert_eq!(connection_data.counts.normal, 0); - assert_eq!(connection_data.counts.soft, 0); - assert_connection_ownership_invariants(connection_data); - } - - fn assert_active_stream_kind(connection_data: &UserConnectionData, stream_uid: u32, expected_kind: ConnectionKind) { - assert!( - connection_data.streams.iter().any(|stream| stream.uid == stream_uid && !stream.preserved), - "stream {stream_uid} must remain active" - ); - assert_eq!(connection_data.stream_kinds.get(&stream_uid), Some(&expected_kind)); - } - - fn assert_single_normal_stream_slot(connection_data: &UserConnectionData, stream_uid: u32) { - assert_eq!(connection_data.connections, 1); - assert_eq!(connection_data.counts.normal, 1); - assert_eq!(connection_data.counts.soft, 0); - assert_active_stream_kind(connection_data, stream_uid, ConnectionKind::Normal); - assert_connection_ownership_invariants(connection_data); - } - - fn assert_preserved_session_is_uncounted( - connection_data: &UserConnectionData, - session_token: &str, - stream_uid: u32, - ) { - let session = connection_data - .sessions - .iter() - .find(|session| session.token == session_token) - .expect("preserved session must exist"); - assert_eq!(session.lifecycle, PlaybackLifecycle::Preserved); - - let stream = connection_data - .streams - .iter() - .find(|stream| stream.uid == stream_uid) - .expect("preserved stream must exist"); - assert!(stream.preserved); - assert_eq!(stream.session_token.as_deref(), Some(session_token)); - assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); - } - - async fn commit_and_preserve_adaptive_session( - manager: &ActiveUserManager, - user: &ProxyUserCredentials, - session_token: &str, - stream_uid: u32, - addr: SocketAddr, - connection_kind: ConnectionKind, - ) { - let fingerprint = Fingerprint::new(format!("fp-preserved-{stream_uid}"), addr.ip().to_string(), addr); - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user, - session_token, - virtual_id: stream_uid, - provider: "provider-a", - stream_url: "http://localhost/live-preserved.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(connection_kind), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: stream_uid, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(stream_uid), - user_agent: Cow::Borrowed("ua"), - session_token: Some(session_token), - }) - .await - .expect("adaptive session stream should bind"); - - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - let session = connection_data - .sessions - .iter() - .find(|session| session.token == session_token) - .expect("committed session must exist"); - assert_eq!(session.lifecycle, PlaybackLifecycle::Active); - assert_eq!(connection_data.stream_kinds.get(&stream_uid), Some(&connection_kind)); - assert_connection_ownership_invariants(connection_data); - } - - assert!(manager.release_stream(&addr).await.is_none(), "adaptive stream should be preserved"); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); - assert_connection_ownership_invariants(connection_data); - } - - #[tokio::test] - async fn eviction_candidates_ignore_ambiguous_socket_addrs() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let shared_addr: SocketAddr = "127.0.0.1:55031".parse().unwrap(); - let unique_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap(); - let shared_fp = Fingerprint::new("fp-shared".to_string(), "127.0.0.1".to_string(), shared_addr); - let unique_fp = Fingerprint::new("fp-unique".to_string(), "127.0.0.1".to_string(), unique_addr); - - manager.add_connection(&shared_addr).await; - manager.add_connection(&unique_addr).await; - - // Create sessions first so update_connection can mark them as counted. - let user = test_user_credentials("same-user", 3, 0); - for (token, addr, channel_id) in - [("tok-31", shared_addr, 1031u32), ("tok-32", shared_addr, 1032), ("tok-33", unique_addr, 1033)] - { - manager - .create_user_session(crate::CreateUserSessionParams { - user: &user, - session_token: token, - virtual_id: channel_id, - provider: "provider-a", - stream_url: "", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - } - - // update_connection marks the session as counted. - for (uid, token, fp, channel_id) in [(31, "tok-31", &shared_fp, 1031u32), (32, "tok-32", &shared_fp, 1032)] { - manager - .update_connection(ActiveUserConnectionParams { - uid, - meter_uid: 0, - username: "same-user", - max_connections: 3, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: fp, - provider: "provider-a".intern(), - stream_channel: &test_channel(channel_id), - user_agent: Cow::Borrowed("ua"), - session_token: Some(token), - }) - .await; - } - - manager - .update_connection(ActiveUserConnectionParams { - uid: 33, - meter_uid: 0, - username: "same-user", - max_connections: 3, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &unique_fp, - provider: "provider-a".intern(), - stream_channel: &test_channel(1033), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-33"), - }) - .await; - - let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await; - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].addr, unique_addr); - } - - #[tokio::test] - async fn eviction_candidates_include_other_ips_for_user_wide_rules() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let first_addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); - let second_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap(); - let first_fp = Fingerprint::new("fp-user-wide-1".to_string(), "10.0.0.1".to_string(), first_addr); - let second_fp = Fingerprint::new("fp-user-wide-2".to_string(), "10.0.0.2".to_string(), second_addr); - - manager.add_connection(&first_addr).await; - manager.add_connection(&second_addr).await; - - let user = test_user_credentials("same-user", 2, 0); - for (token, addr, channel_id) in [("tok-41", first_addr, 1041u32), ("tok-42", second_addr, 1042)] { - manager - .create_user_session(crate::CreateUserSessionParams { - user: &user, - session_token: token, - virtual_id: channel_id, - provider: "provider-a", - stream_url: "", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - } - - manager - .update_connection(ActiveUserConnectionParams { - uid: 41, - meter_uid: 0, - username: "same-user", - max_connections: 2, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &first_fp, - provider: "provider-a".intern(), - stream_channel: &test_channel(1041), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-41"), - }) - .await; - - manager - .update_connection(ActiveUserConnectionParams { - uid: 42, - meter_uid: 0, - username: "same-user", - max_connections: 2, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &second_fp, - provider: "provider-a".intern(), - stream_channel: &test_channel(1042), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-42"), - }) - .await; - - let candidates = manager.get_eviction_candidates("same-user", "10.0.0.1").await; - assert_eq!(candidates.len(), 2); - assert!(candidates.iter().any(|candidate| candidate.addr == first_addr)); - assert!(candidates.iter().any(|candidate| candidate.addr == second_addr)); - } - - #[tokio::test] - async fn eviction_candidates_include_preserved_adaptive_streams() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55043".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-preserved".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("same-user"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-preserved", - virtual_id: 1043, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 43, - meter_uid: 0, - username: "same-user", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(1043) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-preserved"), - }) - .await; - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty(), "adaptive stream should stay logically active"); - - let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await; - assert_eq!(candidates.len(), 1, "preserved adaptive streams must remain evictable"); - assert_eq!(candidates[0].addr, addr); - } - - #[tokio::test] - async fn test_kicked_release_does_not_preserve_adaptive_stream() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55014".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-adaptive".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-adaptive"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-adaptive", - virtual_id: 2014, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 14, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(2014), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-adaptive"), - }) - .await; - - let removed = manager.release_connection_as_kicked(&addr).await; - assert!(removed.addr_removed); - assert_eq!(removed.removed_streams.len(), 1); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn kicked_release_removes_preserved_adaptive_stream_without_socket_registration() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-preserved-kick".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-preserved-kick"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-preserved-kick", - virtual_id: 2017, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 17, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(2017), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-preserved-kick"), - }) - .await; - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty()); - assert!(manager.active_streams().await.is_empty()); - - let kicked = manager.release_connection_as_kicked(&addr).await; - assert!(kicked.addr_removed); - assert_eq!(kicked.removed_streams.len(), 1); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn test_kicked_release_invalidates_removed_session_tokens() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let kicked_addr: SocketAddr = "127.0.0.1:55015".parse().unwrap(); - let survivor_addr: SocketAddr = "127.0.0.1:55016".parse().unwrap(); - let kicked_fingerprint = Fingerprint::new("fp-kicked".to_string(), "127.0.0.1".to_string(), kicked_addr); - let survivor_fingerprint = Fingerprint::new("fp-survivor".to_string(), "127.0.0.1".to_string(), survivor_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("kicked-user"); - user.max_connections = 1; - - manager.add_connection(&kicked_addr).await; - manager.add_connection(&survivor_addr).await; - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-kicked", - virtual_id: 2015, - provider: "provider-a", - stream_url: "http://localhost/live-1.ts", - addr: &kicked_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: true, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-survivor", - virtual_id: 2016, - provider: "provider-a", - stream_url: "http://localhost/live-2.ts", - addr: &survivor_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: true, - }) - .await; - - manager - .update_connection(ActiveUserConnectionParams { - uid: 15, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &kicked_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(2015), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-kicked"), - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 16, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &survivor_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(2016), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-survivor"), - }) - .await; - - let removed = manager.release_connection_as_kicked(&kicked_addr).await; - assert!(removed.addr_removed); - assert_eq!(removed.removed_streams.len(), 1); - assert_eq!( - manager.connection_admission_for_session(&user.username, 1, 0, "tok-kicked").await.permission, - UserConnectionPermission::Exhausted - ); - assert_eq!( - manager.connection_admission_for_session(&user.username, 1, 0, "tok-survivor").await.permission, - UserConnectionPermission::Allowed - ); - } - - #[tokio::test] - async fn test_grace_at_limit_remains_active_until_connections_drop_below_limit() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-grace".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 17, - meter_uid: 0, - username: "grace-at-limit", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(2017), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-grace"), - }) - .await; - - assert!(manager.grant_grace("grace-at-limit").await); - assert_eq!( - manager.connection_admission("grace-at-limit", 1, 0).await.permission, - UserConnectionPermission::Exhausted - ); - assert!(!manager.grant_grace("grace-at-limit").await); - } - - #[tokio::test] - async fn test_same_session_token_on_new_addr_reuses_logical_connection() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let first_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap(); - let second_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap(); - let first = Fingerprint::new("fp-key-1".to_string(), "127.0.0.1".to_string(), first_addr); - let second = Fingerprint::new("fp-key-2".to_string(), "127.0.0.1".to_string(), second_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&first_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls", - virtual_id: 2001, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 0, - meter_uid: 0, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &first, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(2001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-hls"), - }) - .await; - - assert_eq!( - manager.connection_permission_for_session("user1", 1, 0, "tok-hls").await, - UserConnectionPermission::Allowed - ); - - manager.add_connection(&second_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 0, - meter_uid: 0, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &second, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(2001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-hls"), - }) - .await; - - assert_eq!(manager.user_connections("user1").await, 1); - - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert_eq!(streams[0].addr, second_addr); - assert_eq!(streams[0].session_token.as_deref(), Some("tok-hls")); - } - - #[tokio::test] - async fn adaptive_session_stream_cleanup_addrs_excludes_manifest_addr_and_current_addr() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let manifest_addr: SocketAddr = "127.0.0.1:55091".parse().unwrap(); - let first_segment_addr: SocketAddr = "10.41.41.89:55092".parse().unwrap(); - let next_segment_addr: SocketAddr = "10.41.41.89:55093".parse().unwrap(); - let first_segment = Fingerprint::new("fp-segment-1".to_string(), "10.41.41.89".to_string(), first_segment_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&manifest_addr).await; - manager.add_connection(&first_segment_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-cleanup", - virtual_id: 2002, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &manifest_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 0, - meter_uid: 0, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &first_segment, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(2002) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-hls-cleanup"), - }) - .await; - - assert_eq!( - manager.adaptive_session_stream_cleanup_addrs("user1", "tok-hls-cleanup", &next_segment_addr).await, - vec![first_segment_addr] - ); - } - - #[tokio::test] - async fn adaptive_session_stream_cleanup_addrs_falls_back_to_same_ip_session_addrs() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let manifest_addr: SocketAddr = "127.0.0.1:55101".parse().unwrap(); - let first_segment_addr: SocketAddr = "10.41.41.89:55102".parse().unwrap(); - let next_segment_addr: SocketAddr = "10.41.41.89:55103".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user2"); - user.max_connections = 1; - - manager.add_connection(&manifest_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-cleanup-fallback", - virtual_id: 2003, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &manifest_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &first_segment_addr).await; - manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &next_segment_addr).await; - - assert_eq!( - manager - .adaptive_session_stream_cleanup_addrs("user2", "tok-hls-cleanup-fallback", &next_segment_addr) - .await, - vec![first_segment_addr] - ); - } - - #[tokio::test] - async fn recently_evicted_session_guard_survives_ttl_while_protected_addr_is_still_active() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let evicted_addr: SocketAddr = "127.0.0.1:55111".parse().unwrap(); - let protected_addr: SocketAddr = "127.0.0.1:55112".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-guard-session".to_string(), "127.0.0.1".to_string(), evicted_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("guard-user"); - user.max_connections = 1; - - manager.add_connection(&evicted_addr).await; - manager.add_connection(&protected_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-guard-session", - virtual_id: 2018, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &evicted_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 18, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(2018), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-guard-session"), - }) - .await; - - manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 1).await; - { - let mut connections = manager.connections.write().await; - if let Some(registration) = connections.key_by_addr.get_mut(&protected_addr) { - registration.username = user.username.clone(); - } - let protection = connections - .recently_evicted_sessions - .get_mut("tok-guard-session") - .expect("recent eviction guard should exist"); - protection.expires_at = current_time_secs().saturating_sub(1); - } - - assert_eq!(manager.recently_evicted_session_protected_addr("tok-guard-session").await, Some(protected_addr)); - } - - #[tokio::test] - async fn recently_evicted_vod_uses_session_reentry_guard() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let evicted_addr: SocketAddr = "127.0.0.1:55113".parse().unwrap(); - let protected_addr: SocketAddr = "127.0.0.1:55114".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-vod-guard".to_string(), "127.0.0.1".to_string(), evicted_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("vod-guard-user"); - user.max_connections = 1; - let mut channel = test_channel(2019); - channel.item_type = PlaylistItemType::Video; - channel.cluster = XtreamCluster::Video; - channel.url = "http://localhost/movie.mkv".intern(); - - manager.add_connection(&evicted_addr).await; - manager.add_connection(&protected_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-guard-vod", - virtual_id: channel.virtual_id, - provider: "provider-a", - stream_url: channel.url.as_ref(), - addr: &evicted_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 19, - meter_uid: 0, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-guard-vod"), - }) - .await; - - manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10).await; - - assert_eq!(manager.recently_evicted_session_protected_addr("tok-guard-vod").await, Some(protected_addr)); - let connections = manager.connections.read().await; - assert!( - connections.recent_socket_reentry_guards.is_empty(), - "provider-affine VOD must not be guarded by transient socket identity" - ); - } - - #[tokio::test] - async fn provider_affine_stream_without_session_token_uses_socket_reentry_fallback() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let evicted_addr: SocketAddr = "127.0.0.1:55115".parse().unwrap(); - let protected_addr: SocketAddr = "127.0.0.1:55116".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-vod-no-token".to_string(), "127.0.0.1".to_string(), evicted_addr); - let mut channel = test_channel(2020); - channel.item_type = PlaylistItemType::Video; - channel.cluster = XtreamCluster::Video; - channel.url = "http://localhost/movie-no-token.mkv".intern(); - - manager.add_connection(&evicted_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 20, - meter_uid: 0, - username: "vod-no-token-user", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await; - - manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10).await; - - assert_eq!( - manager - .recent_socket_reentry_protected_addr( - "vod-no-token-user", - "127.0.0.1", - shared::model::VirtualId::new(channel.virtual_id) - ) - .await, - Some(protected_addr) - ); - } - - #[tokio::test] - async fn test_reused_logical_stream_refreshes_normal_priority() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-2a".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-prio", - virtual_id: 2002, - provider: "provider-a", - stream_url: "http://localhost/live-prio.ts", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Soft), - socket_bound: true, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 201, - meter_uid: 0, - username: "user1", - max_connections: 1, - soft_connections: 1, - connection_kind: ConnectionKind::Soft, - priority: 8, - soft_priority: 8, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(2002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-prio"), - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 201, - meter_uid: 0, - username: "user1", - max_connections: 1, - soft_connections: 1, - connection_kind: ConnectionKind::Soft, - priority: -7, - soft_priority: 8, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(2002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-prio"), - }) - .await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get("user1").unwrap(); - assert_eq!(connection_data.stream_normal_priorities.get(&201), Some(&-7)); - } - - #[tokio::test] - async fn test_same_session_token_refreshes_meter_metadata_on_reuse() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55031".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-3".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - let first = manager - .update_connection(ActiveUserConnectionParams { - uid: 11, - meter_uid: 101, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(3001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-meter"), - }) - .await - .expect("initial stream should register"); - assert_eq!(first.uid, 11); - assert_eq!(first.meter_uid, 101); - - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 22, - meter_uid: 202, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-b".intern(), - stream_channel: &test_adaptive_channel(3002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-meter"), - }) - .await - .expect("reused stream should register"); - - assert_eq!(second.uid, 11, "logical stream identity should stay stable on session reuse"); - assert_eq!(second.meter_uid, 202, "reused stream must refresh its meter mapping"); - - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert_eq!(streams[0].uid, 11); - assert_eq!(streams[0].meter_uid, 202); - assert_eq!(streams[0].provider.as_ref(), "provider-b"); - assert_eq!(streams[0].channel.virtual_id, 3002); - } - - #[tokio::test] - async fn socket_bound_live_streams_with_colliding_token_are_tracked_separately() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let Some(addr) = "127.0.0.1:55032".parse::().ok() else { - return; - }; - let fingerprint = Fingerprint::new("fp-key-colliding".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - let first = manager - .update_connection(ActiveUserConnectionParams { - uid: 31, - meter_uid: 301, - username: "user1", - max_connections: 0, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(3003), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-live-colliding"), - }) - .await; - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 32, - meter_uid: 302, - username: "user1", - max_connections: 0, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-b".intern(), - stream_channel: &test_channel(3003), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-live-colliding"), - }) - .await; - - assert!(first.is_some()); - assert!(second.is_some()); - - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 2); - assert!(streams.iter().any(|stream| stream.uid == 31)); - assert!(streams.iter().any(|stream| stream.uid == 32)); - } - - #[tokio::test] - async fn unlimited_user_can_open_same_and_different_live_streams_from_same_ip() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let username = "unlimited-same-ip"; - let client_ip = "10.9.0.1"; - let addrs = [ - "10.9.0.1:55101".parse::().unwrap(), - "10.9.0.1:55102".parse::().unwrap(), - "10.9.0.1:55103".parse::().unwrap(), - ]; - let fingerprints = [ - Fingerprint::new("fp-unlimited-1".to_string(), client_ip.to_string(), addrs[0]), - Fingerprint::new("fp-unlimited-2".to_string(), client_ip.to_string(), addrs[1]), - Fingerprint::new("fp-unlimited-3".to_string(), client_ip.to_string(), addrs[2]), - ]; - - for addr in addrs { - manager.add_connection(&addr).await; - } - - for (idx, (fingerprint, virtual_id)) in fingerprints.iter().zip([4100, 4100, 4101]).enumerate() { - let token = format!("tok-unlimited-{idx}"); - manager - .update_connection(ActiveUserConnectionParams { - uid: 410 + u32::try_from(idx).unwrap_or_default(), - meter_uid: 0, - username, - max_connections: 0, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(virtual_id), - user_agent: Cow::Borrowed("ua"), - session_token: Some(&token), - }) - .await - .expect("unlimited stream should register"); - } - - assert_eq!(manager.user_connections(username).await, 3); - assert_eq!(manager.active_streams().await.len(), 3); - assert_eq!(manager.connection_admission(username, 0, 0).await.permission, UserConnectionPermission::Allowed); - assert_eq!( - manager.connection_admission_for_session(username, 0, 0, "tok-unlimited-new").await.permission, - UserConnectionPermission::Allowed - ); - } - - #[tokio::test] - async fn release_stream_by_uid_removes_only_matching_stream_on_shared_addr() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let Some(addr) = "127.0.0.1:55033".parse::().ok() else { - return; - }; - let fingerprint = Fingerprint::new("fp-key-shared-addr".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - for uid in [41, 42] { - manager - .update_connection(ActiveUserConnectionParams { - uid, - meter_uid: uid + 300, - username: "user1", - max_connections: 0, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(3004), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-live-shared-addr"), - }) - .await; - } - - let removed = manager.release_stream_by_uid(&addr, 42).await; - assert!(removed.as_ref().is_some_and(|stream| stream.uid == 42)); - - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert_eq!(streams[0].uid, 41); - } - - #[tokio::test] - async fn release_stream_by_uid_finds_original_user_after_shared_addr_owner_changes() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55034".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-cross-user-stream".to_string(), "127.0.0.1".to_string(), addr); - manager.add_connection(&addr).await; - - for (uid, username) in [(43, "user-a"), (44, "user-b")] { - manager - .update_connection(ActiveUserConnectionParams { - uid, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_series_channel(3005), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("direct Series stream should register"); - } - - assert_eq!(manager.active_users_and_connections().await, (2, 2)); - assert_eq!(manager.active_streams().await.len(), 2); - - let removed = manager.release_stream_by_uid(&addr, 43).await; - assert!(removed.as_ref().is_some_and(|stream| stream.uid == 43)); - assert_eq!(manager.active_users_and_connections().await, (1, 1)); - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert_eq!(streams[0].uid, 44); - - assert!(manager.release_stream_by_uid(&addr, 44).await.is_some()); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn release_connection_cleans_every_user_stream_for_reused_addr_only() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let reused_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap(); - let unrelated_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap(); - let reused_fingerprint = - Fingerprint::new("fp-cross-user-socket".to_string(), "127.0.0.1".to_string(), reused_addr); - let unrelated_fingerprint = - Fingerprint::new("fp-unrelated-socket".to_string(), "127.0.0.1".to_string(), unrelated_addr); - manager.add_connection(&reused_addr).await; - manager.add_connection(&unrelated_addr).await; - - for (uid, username, fingerprint) in [ - (45, "user-a", &reused_fingerprint), - (46, "user-b", &reused_fingerprint), - (47, "user-a", &unrelated_fingerprint), - ] { - manager - .update_connection(ActiveUserConnectionParams { - uid, - meter_uid: 0, - username, - max_connections: 2, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_series_channel(3006 + uid), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("direct Series stream should register"); - } - - let released = manager.release_connection(&reused_addr).await; - let mut removed_uids = released.removed_streams.iter().map(|stream| stream.uid).collect::>(); - removed_uids.sort_unstable(); - assert!(released.addr_removed); - assert_eq!(removed_uids, vec![45, 46]); - assert_eq!(manager.active_users_and_connections().await, (1, 1)); - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert_eq!(streams[0].uid, 47); - - manager.release_connection(&unrelated_addr).await; - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn connection_counts_are_broadcast_when_active_user_logging_is_disabled() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let mut events = event_manager.get_event_channel(); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55037".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-count-events".to_string(), "127.0.0.1".to_string(), addr); - manager.add_connection(&addr).await; - manager.release_connection(&addr).await; - assert!( - tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(), - "closing an unowned socket must not broadcast unchanged connection counts" - ); - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 48, - meter_uid: 0, - username: "event-user", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_series_channel(3048), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("direct Series stream should register"); - - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), events.recv()) - .await - .expect("connection count update should be broadcast") - .expect("event channel should remain open"), - EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(1, 1)) - ); - - manager - .update_connection(ActiveUserConnectionParams { - uid: 49, - meter_uid: 0, - username: "event-user", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_series_channel(3048), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("same direct Series stream should be reused"); - assert!( - tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(), - "unchanged connection counts must not broadcast another full snapshot" - ); - - manager.release_stream_by_uid(&addr, 48).await.expect("direct Series stream should release"); - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), events.recv()) - .await - .expect("released count update should be broadcast") - .expect("event channel should remain open"), - EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0)) - ); - } - - #[tokio::test] - async fn test_adaptive_session_release_connection_preserves_logical_stream_and_start_time() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); - let next_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-4".to_string(), "127.0.0.1".to_string(), addr); - let next_fingerprint = Fingerprint::new("fp-key-5".to_string(), "127.0.0.1".to_string(), next_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls", - virtual_id: 4001, - 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 first = manager - .update_connection(ActiveUserConnectionParams { - uid: 44, - meter_uid: 144, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(4001) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-hls"), - }) - .await - .expect("initial adaptive session should register"); - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty(), "adaptive session should remain logically active"); - assert_eq!(manager.user_connections("user1").await, 0); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - assert!(manager.active_streams().await.is_empty()); - - let connections = manager.connections.read().await; - let preserved_stream = connections - .by_key - .get("user1") - .and_then(|data| data.streams.iter().find(|stream| stream.uid == 44)) - .expect("preserved adaptive stream should stay internally tracked"); - assert_eq!(preserved_stream.ts, first.ts); - assert!(preserved_stream.preserved); - drop(connections); - - manager.add_connection(&next_addr).await; - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 55, - meter_uid: 155, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &next_fingerprint, - provider: "provider-b".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(4002) }, - user_agent: Cow::Borrowed("ua-2"), - session_token: Some("tok-hls"), - }) - .await - .expect("adaptive session should reuse logical stream"); - - assert_eq!(second.uid, 44); - assert_eq!(second.ts, first.ts, "adaptive session duration must stay session-based"); - assert_eq!(second.addr, next_addr); - assert_eq!(second.meter_uid, 155); - assert_eq!(manager.user_connections("user1").await, 1); - - let streams = manager.active_streams().await; - assert_eq!(streams.len(), 1); - assert!(!streams[0].preserved); - } - - #[tokio::test] - async fn test_release_stream_ignores_preserved_adaptive_entry() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55051".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-6".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls", - virtual_id: 5001, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 66, - meter_uid: 166, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(5001) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-hls"), - }) - .await; - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty()); - assert!(manager.release_stream(&addr).await.is_none()); - } - - #[tokio::test] - async fn test_preserved_adaptive_stream_is_pruned_after_session_ttl() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55061".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-7".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-expire", - virtual_id: 6001, - provider: "provider-a", - stream_url: "http://localhost/hls.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 77, - meter_uid: 177, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(6001) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-expire"), - }) - .await; - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - - { - let mut connections = manager.connections.write().await; - let connection_data = connections.by_key.get_mut("user1").unwrap(); - let session = connection_data.sessions.iter_mut().find(|session| session.token == "tok-expire").unwrap(); - session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); - } - if let Some(gc_ts) = &manager.gc_ts { - gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); - } - - manager - .process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1)) - .await; - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn test_due_adaptive_expiry_removal_promotes_soft_stream() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let normal_addr: SocketAddr = "127.0.0.1:55062".parse().unwrap(); - let soft_addr: SocketAddr = "127.0.0.1:55063".parse().unwrap(); - let normal_fp = Fingerprint::new("fp-key-7a".to_string(), "127.0.0.1".to_string(), normal_addr); - let soft_fp = Fingerprint::new("fp-key-7b".to_string(), "127.0.0.1".to_string(), soft_addr); - - manager.add_connection(&normal_addr).await; - manager.add_connection(&soft_addr).await; - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - user.soft_connections = 1; - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-expire-normal", - virtual_id: 6002, - provider: "provider-a", - stream_url: "http://localhost/hls-normal.m3u8", - addr: &normal_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 78, - meter_uid: 178, - username: "user1", - max_connections: 1, - soft_connections: 1, - connection_kind: ConnectionKind::Normal, - priority: -1, - soft_priority: 9, - fingerprint: &normal_fp, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(6002) }, - user_agent: Cow::Borrowed("ua-normal"), - session_token: Some("tok-expire-normal"), - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 79, - meter_uid: 179, - username: "user1", - max_connections: 1, - soft_connections: 1, - connection_kind: ConnectionKind::Soft, - priority: -5, - soft_priority: 9, - fingerprint: &soft_fp, - provider: "provider-a".intern(), - stream_channel: &test_channel(6003), - user_agent: Cow::Borrowed("ua-soft"), - session_token: None, - }) - .await; - - let released = manager.release_connection(&normal_addr).await; - assert!(released.addr_removed); - - { - let mut connections = manager.connections.write().await; - let connection_data = connections.by_key.get_mut("user1").unwrap(); - let session = - connection_data.sessions.iter_mut().find(|session| session.token == "tok-expire-normal").unwrap(); - session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); - } - - manager - .process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1)) - .await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get("user1").unwrap(); - assert_eq!(connection_data.stream_kinds.get(&79), Some(&ConnectionKind::Soft)); - assert!(!connection_data.stream_normal_priorities.contains_key(&78)); - } - - #[tokio::test] - async fn test_repeated_preserve_for_same_adaptive_session_keeps_single_current_expiry_index() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr_a: SocketAddr = "127.0.0.1:55071".parse().unwrap(); - let addr_b: SocketAddr = "127.0.0.1:55072".parse().unwrap(); - let fp_a = Fingerprint::new("fp-key-a".to_string(), "127.0.0.1".to_string(), addr_a); - let fp_b = Fingerprint::new("fp-key-b".to_string(), "127.0.0.1".to_string(), addr_b); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr_a).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-reuse", - virtual_id: 7001, - provider: "provider-a", - stream_url: "http://localhost/live-a.m3u8", - addr: &addr_a, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 88, - meter_uid: 188, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fp_a, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(7001) }, - user_agent: Cow::Borrowed("ua-a"), - session_token: Some("tok-reuse"), - }) - .await; - let released = manager.release_connection(&addr_a).await; - assert!(released.addr_removed); - - manager.add_connection(&addr_b).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 99, - meter_uid: 199, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fp_b, - provider: "provider-b".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(7002) }, - user_agent: Cow::Borrowed("ua-b"), - session_token: Some("tok-reuse"), - }) - .await; - let released = manager.release_connection(&addr_b).await; - assert!(released.addr_removed); - - let expiry_index = manager.adaptive_expiry_index.lock().await; - assert_eq!(expiry_index.len(), 1); - assert!(expiry_index.contains_key(&AdaptiveExpiryKey { - username: String::from("user1"), - session_token: String::from("tok-reuse"), - uid: 88, - })); - } - - #[tokio::test] - async fn test_release_stream_preserved_path_emits_connection_update_event() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - let mut events = event_manager.get_event_channel(); - - let addr: SocketAddr = "127.0.0.1:55081".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-8".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-event", - virtual_id: 8001, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 111, - meter_uid: 211, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8001) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-event"), - }) - .await; - let _ = events.try_recv(); - - let released = manager.release_stream(&addr).await; - assert!(released.is_none(), "adaptive stream should remain logically preserved"); - - let event = events.try_recv().expect("preserved release should emit an ActiveUser event"); - assert!(matches!(event, EventMessage::ActiveUser(_))); - } - - #[tokio::test] - async fn test_release_stream_without_session_removes_adaptive_stream_instead_of_preserving() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55082".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-9".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 122, - meter_uid: 222, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8002) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("missing-session"), - }) - .await; - - let released = manager.release_stream(&addr).await; - assert!(released.is_some(), "stream without schedulable expiry must be removed"); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn test_due_adaptive_expiry_reschedules_when_session_timestamp_changes() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55083".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-10".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-reschedule", - virtual_id: 8003, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 133, - meter_uid: 233, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8003) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-reschedule"), - }) - .await; - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - - let key = AdaptiveExpiryKey { - username: String::from("user1"), - session_token: String::from("tok-reschedule"), - uid: 133, - }; - let old_expires_at = { - let expiry_index = manager.adaptive_expiry_index.lock().await; - *expiry_index.get(&key).unwrap() - }; - - { - let mut connections = manager.connections.write().await; - let session = connections - .by_key - .get_mut("user1") - .unwrap() - .sessions - .iter_mut() - .find(|session| session.token == "tok-reschedule") - .unwrap(); - session.ts = session.ts.saturating_add(30); - } - - manager.process_due_adaptive_expiry_entries(old_expires_at).await; - - let new_expires_at = { - let expiry_index = manager.adaptive_expiry_index.lock().await; - *expiry_index.get(&key).unwrap() - }; - assert!(new_expires_at > old_expires_at); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn test_due_adaptive_expiry_removes_stale_index_when_preserved_stream_missing() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-11a".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-stale", - virtual_id: 8004, - provider: "provider-a", - stream_url: "http://localhost/stale.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 134, - meter_uid: 234, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8004) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-stale"), - }) - .await; - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - - let key = - AdaptiveExpiryKey { username: String::from("user1"), session_token: String::from("tok-stale"), uid: 134 }; - let old_expires_at = { - let expiry_index = manager.adaptive_expiry_index.lock().await; - *expiry_index.get(&key).unwrap() - }; - - { - let mut connections = manager.connections.write().await; - let connection_data = connections.by_key.get_mut("user1").unwrap(); - connection_data.streams.clear(); - } - - manager.process_due_adaptive_expiry_entries(old_expires_at).await; - - let expiry_index = manager.adaptive_expiry_index.lock().await; - assert!(!expiry_index.contains_key(&key)); - } - - #[tokio::test] - async fn test_due_adaptive_expiry_does_not_block_on_full_cleanup_channel() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55084".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-11".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-full-channel", - virtual_id: 8004, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 144, - meter_uid: 244, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8004) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-full-channel"), - }) - .await; - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - - { - let mut connections = manager.connections.write().await; - let session = connections - .by_key - .get_mut("user1") - .unwrap() - .sessions - .iter_mut() - .find(|session| session.token == "tok-full-channel") - .unwrap(); - session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); - } - - let (cleanup_tx, mut cleanup_rx) = mpsc::channel(1); - cleanup_tx.send(CleanupEvent::ReleaseConnection { addr }).await.expect("prefill cleanup channel"); - manager.set_cleanup_sender(cleanup_tx); - - let process_result = tokio::time::timeout( - Duration::from_millis(100), - manager.process_due_adaptive_expiry_entries( - current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1), - ), - ) - .await; - - assert!(process_result.is_ok(), "adaptive expiry processing must not await while holding locks"); - - let queued_event = cleanup_rx.try_recv().expect("prefilled cleanup event should remain queued"); - assert!(matches!(queued_event, CleanupEvent::ReleaseConnection { .. })); - assert!(manager.active_streams().await.is_empty()); - } - - #[tokio::test] - async fn test_preserved_adaptive_stream_reconnect_across_day_sets_previous_session_id() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap(); - let next_addr: SocketAddr = "127.0.0.1:55086".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-rollover-a".to_string(), "127.0.0.1".to_string(), addr); - let next_fingerprint = Fingerprint::new("fp-rollover-b".to_string(), "127.0.0.1".to_string(), next_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-rollover", - virtual_id: 8005, - 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 first = manager - .update_connection(ActiveUserConnectionParams { - uid: 145, - meter_uid: 245, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8005) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-rollover"), - }) - .await - .expect("initial adaptive session should register"); - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - - let forced_old_ts = { - let mut connections = manager.connections.write().await; - let stream = connections - .by_key - .get_mut("user1") - .unwrap() - .streams - .iter_mut() - .find(|stream| stream.session_token.as_deref() == Some("tok-rollover")) - .unwrap(); - stream.ts = stream.ts.saturating_sub(86_400); - stream.ts - }; - - manager.add_connection(&next_addr).await; - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 146, - meter_uid: 246, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &next_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(8005) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-rollover"), - }) - .await - .expect("adaptive session should reconnect"); - - assert_eq!(second.previous_session_id, Some((forced_old_ts << 32) | u64::from(first.uid))); - assert!(second.ts > forced_old_ts); - assert_eq!(utc_day_from_secs(second.ts), utc_day_from_secs(current_time_secs())); - } - - #[tokio::test] - async fn stale_anonymous_socket_registration_is_pruned_by_gc() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let stale_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap(); - let fresh_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap(); - - manager.add_connection(&stale_addr).await; - { - let mut connections = manager.connections.write().await; - let registration = connections.key_by_addr.get_mut(&stale_addr).expect("socket registration should exist"); - registration.ts = registration.ts.saturating_sub(ANON_SOCKET_TTL + 1); - } - - if let Some(gc_ts) = &manager.gc_ts { - gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); - } - - manager.add_connection(&fresh_addr).await; - - let connections = manager.connections.read().await; - assert!(!connections.key_by_addr.contains_key(&stale_addr)); - assert!(connections.key_by_addr.contains_key(&fresh_addr)); - } - - #[tokio::test] - async fn named_socket_registration_exposes_expiry_deadline() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let stale_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap(); - let fresh_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap(); - let stale_fp = Fingerprint::new("fp-stale".to_string(), "127.0.0.1".to_string(), stale_addr); - let fresh_fp = Fingerprint::new("fp-fresh".to_string(), "127.0.0.1".to_string(), fresh_addr); - let mut stale_user = ProxyUserCredentials::default(); - stale_user.username = "user1".to_string(); - stale_user.max_connections = 1; - let mut fresh_user = ProxyUserCredentials::default(); - fresh_user.username = "user2".to_string(); - fresh_user.max_connections = 1; - - manager.add_connection(&stale_addr).await; - manager.add_connection(&fresh_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &stale_user, - session_token: "tok-stale-deadline", - virtual_id: 9201, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &stale_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &fresh_user, - session_token: "tok-fresh-deadline", - virtual_id: 9202, - provider: "provider-b", - stream_url: "http://localhost/live.m3u8", - addr: &fresh_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 201, - meter_uid: 301, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &stale_fp, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9201), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-stale-deadline"), - }) - .await - .expect("stale stream should register"); - manager - .update_connection(ActiveUserConnectionParams { - uid: 202, - meter_uid: 302, - username: "user2", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fresh_fp, - provider: "provider-b".intern(), - stream_channel: &test_adaptive_channel(9202), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-fresh-deadline"), - }) - .await - .expect("fresh stream should register"); - - { - let mut connections = manager.connections.write().await; - let stale_registration = - connections.key_by_addr.get_mut(&stale_addr).expect("stale registration should exist"); - stale_registration.ts = stale_registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 1); - } - - let stale_deadline = manager - .socket_expiry_deadline(&stale_addr) - .await - .expect("stale named socket should have an expiry deadline"); - let fresh_deadline = manager - .socket_expiry_deadline(&fresh_addr) - .await - .expect("fresh named socket should have an expiry deadline"); - assert!(stale_deadline < fresh_deadline); - } - - #[tokio::test] - async fn touch_http_activity_refreshes_session_and_registration_without_stream() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-http-touch", - virtual_id: 9302, - 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 previous_ts = { - let mut connections = manager.connections.write().await; - let previous_ts = { - let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); - registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); - registration.ts - }; - let connection_data = connections.by_key.get_mut("user1").expect("user should exist"); - connection_data.sessions[0].ts = - connection_data.sessions[0].ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); - previous_ts - }; - - manager.touch_http_activity("user1", "tok-http-touch", &addr).await; - - let connections = manager.connections.read().await; - let registration = connections.key_by_addr.get(&addr).expect("registration should still exist"); - let connection_data = connections.by_key.get("user1").expect("user should still exist"); - assert!(registration.ts > previous_ts); - assert!(connection_data.sessions[0].ts >= registration.ts); - } - - #[tokio::test] - async fn touch_http_activity_does_not_reset_stream_started_at_ts() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr1: SocketAddr = "127.0.0.1:55030".parse().unwrap(); - let addr2: SocketAddr = "127.0.0.1:55031".parse().unwrap(); - let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr1); - let mut user = ProxyUserCredentials::default(); - user.username = "user-touch-ts".to_string(); - - manager.add_connection(&addr1).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-ts", - virtual_id: 7777, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr1, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Simulate first HLS segment: creates the stream entry with ts = now - manager - .update_connection(ActiveUserConnectionParams { - uid: 601, - meter_uid: 701, - username: "user-touch-ts", - max_connections: 0, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(7777), - user_agent: Cow::Borrowed("player/1.0"), - session_token: Some("tok-hls-ts"), - }) - .await - .expect("stream should be created"); - - // Record the original stream start timestamp - let original_ts = { - let connections = manager.connections.read().await; - connections - .by_key - .get("user-touch-ts") - .and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts"))) - .map(|s| s.ts) - .expect("stream should exist") - }; - - // Simulate manifest re-fetch (touch_http_activity called with a new addr) - manager.touch_http_activity("user-touch-ts", "tok-hls-ts", &addr2).await; - - // stream.ts must NOT have been reset — it represents session start time shown as Duration - let connections = manager.connections.read().await; - let stream = connections - .by_key - .get("user-touch-ts") - .and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts"))) - .expect("stream should still exist"); - assert_eq!(stream.ts, original_ts, "touch_http_activity must not reset the stream start timestamp"); - // Lightweight manifest activity must not move the active stream socket. - assert_eq!(stream.addr, addr1, "touch_http_activity must not replace the active stream addr"); - } - - #[tokio::test] - async fn touch_http_activity_does_not_migrate_adaptive_stream_to_manifest_addr_on_close() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - let mut events = event_manager.get_event_channel(); - - let segment_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap(); - let manifest_addr: SocketAddr = "127.0.0.1:55033".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-hls-segment".to_string(), "127.0.0.1".to_string(), segment_addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-hls-manifest-touch".to_string(); - user.max_connections = 1; - - manager.add_connection(&segment_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-manifest-touch", - virtual_id: 7788, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &segment_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 602, - meter_uid: 702, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(7788), - user_agent: Cow::Borrowed("player/1.0"), - session_token: Some("tok-hls-manifest-touch"), - }) - .await - .expect("stream should be created"); - - manager.touch_http_activity(&user.username, "tok-hls-manifest-touch", &manifest_addr).await; - - let released = manager.release_connection(&segment_addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty(), "adaptive close should preserve without history removal"); - assert_eq!(manager.user_connections(&user.username).await, 0); - assert!( - manager.active_streams().await.is_empty(), - "preserved rows stay out of active_streams (use panel_streams for StatusCheck)" - ); - let panel = manager.panel_streams().await; - assert_eq!(panel.len(), 1, "preserved adaptive/catchup session rows stay in panel snapshots"); - assert!(panel[0].preserved); - assert_eq!(panel[0].session_token.as_deref(), Some("tok-hls-manifest-touch")); - - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user should remain for preserved session"); - let stream = data - .streams - .iter() - .find(|stream| stream.session_token.as_deref() == Some("tok-hls-manifest-touch")) - .expect("preserved stream should remain internally tracked"); - assert!(stream.preserved); - assert_eq!(stream.addr, segment_addr, "closed segment must not migrate to manifest addr"); - assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); - drop(connections); - - let mut saw_preserved_update = false; - while let Ok(event) = events.try_recv() { - if matches!(event, EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream)) if stream.addr == segment_addr && stream.preserved) - { - saw_preserved_update = true; - } - } - assert!( - saw_preserved_update, - "preserving a stream must notify the frontend so adaptive TTL cleanup can hide it" - ); - } - - #[tokio::test] - async fn clear_unbound_session_addr_prunes_manifest_addr_while_stream_is_active_elsewhere() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let segment_addr: SocketAddr = "127.0.0.1:55034".parse().unwrap(); - let manifest_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-hls-segment-2".to_string(), "127.0.0.1".to_string(), segment_addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-hls-manifest-clear".to_string(); - user.max_connections = 1; - - manager.add_connection(&segment_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-manifest-clear", - virtual_id: 7789, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &segment_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 603, - meter_uid: 703, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(7789), - user_agent: Cow::Borrowed("player/1.0"), - session_token: Some("tok-hls-manifest-clear"), - }) - .await - .expect("stream should be created"); - - manager.add_connection(&manifest_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-manifest-clear", - virtual_id: 7789, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &manifest_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - manager.clear_unbound_session_addr(&user.username, "tok-hls-manifest-clear", &manifest_addr).await; - - let connections = manager.connections.read().await; - assert!(!connections.key_by_addr.contains_key(&manifest_addr)); - let data = connections.by_key.get(&user.username).expect("user should exist"); - assert_eq!(data.streams[0].addr, segment_addr); - assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); - } - - #[tokio::test] - async fn clear_unbound_session_addr_prunes_touch_only_manifest_addr() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let segment_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap(); - let manifest_addr: SocketAddr = "127.0.0.1:55037".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-hls-segment-3".to_string(), "127.0.0.1".to_string(), segment_addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-hls-manifest-touch-clear".to_string(); - user.max_connections = 1; - - manager.add_connection(&segment_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-hls-manifest-touch-clear", - virtual_id: 7790, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &segment_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 604, - meter_uid: 704, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(7790), - user_agent: Cow::Borrowed("player/1.0"), - session_token: Some("tok-hls-manifest-touch-clear"), - }) - .await - .expect("stream should be created"); - - manager.touch_http_activity(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr).await; - manager.clear_unbound_session_addr(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr).await; - - let connections = manager.connections.read().await; - assert!(!connections.key_by_addr.contains_key(&manifest_addr)); - let data = connections.by_key.get(&user.username).expect("user should exist"); - assert_eq!(data.streams[0].addr, segment_addr); - assert_eq!(data.sessions[0].addr, segment_addr); - assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); - } - - #[tokio::test] - async fn socket_expiry_deadline_does_not_refresh_active_vod_streams_without_activity() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55040".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-vod".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-vod-expiry".to_string(); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-vod-expiry", - virtual_id: 8888, - provider: "provider-a", - stream_url: "http://localhost/movie.mkv", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let mut channel = test_channel(8888); - channel.item_type = PlaylistItemType::Video; - channel.cluster = XtreamCluster::Video; - channel.url = "http://localhost/movie.mkv".intern(); - - manager - .update_connection(ActiveUserConnectionParams { - uid: 602, - meter_uid: 702, - username: "user-vod-expiry", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: Cow::Borrowed("player/1.0"), - session_token: Some("tok-vod-expiry"), - }) - .await - .expect("vod stream should be created"); - - let previous_registration_ts = { - let mut connections = manager.connections.write().await; - let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); - registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); - registration.ts - }; - - let deadline = - manager.socket_expiry_deadline(&addr).await.expect("VOD streams should stay scheduled for expiry tracking"); - - let unchanged_registration_ts = { - let connections = manager.connections.read().await; - connections.key_by_addr.get(&addr).expect("registration should still exist").ts - }; - - assert_eq!(unchanged_registration_ts, previous_registration_ts); - assert_eq!( - deadline, - previous_registration_ts.saturating_add(manager.active_socket_ttl_secs()), - "deadline checks must not refresh VOD sockets without real body activity" - ); - } - - #[tokio::test] - async fn touch_socket_activity_refreshes_registration_without_resetting_stream_start() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-vod-touch".to_string(), "127.0.0.1".to_string(), addr); - let mut user = ProxyUserCredentials::default(); - user.username = "user-vod-touch".to_string(); - user.max_connections = 1; - - manager.add_connection(&addr).await; - let mut channel = test_channel(8889); - channel.item_type = PlaylistItemType::Video; - channel.cluster = XtreamCluster::Video; - channel.url = "http://localhost/movie-2.mkv".intern(); - - let stream = manager - .update_connection(ActiveUserConnectionParams { - uid: 603, - meter_uid: 703, - username: "user-vod-touch", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &channel, - user_agent: Cow::Borrowed("player/1.0"), - session_token: None, - }) - .await - .expect("vod stream should be created"); - - let stale_registration_ts = { - let mut connections = manager.connections.write().await; - let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); - registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); - registration.ts - }; - - manager.touch_socket_activity(&addr).await; - - let (refreshed_registration_ts, stream_started_at) = { - let connections = manager.connections.read().await; - let registration_ts = connections.key_by_addr.get(&addr).expect("registration should still exist").ts; - let stream_started_at = connections - .by_key - .get("user-vod-touch") - .and_then(|data| data.streams.iter().find(|active| active.uid == stream.uid)) - .expect("stream should still exist") - .ts; - (registration_ts, stream_started_at) - }; - - assert!(refreshed_registration_ts > stale_registration_ts); - assert_eq!(stream_started_at, stream.ts, "body activity must not reset visible stream duration"); - } - - #[tokio::test] - async fn update_session_addr_prunes_previous_registration_for_socket_bound_session() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let old_addr: SocketAddr = "127.0.0.1:55121".parse().unwrap(); - let new_addr: SocketAddr = "127.0.0.1:55122".parse().unwrap(); - let old_fingerprint = Fingerprint::new("fp-old".to_string(), "127.0.0.1".to_string(), old_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&old_addr).await; - manager.add_connection(&new_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-move", - virtual_id: 9101, - provider: "provider-a", - stream_url: "http://localhost/live.ts", - addr: &old_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: true, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 301, - meter_uid: 401, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &old_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::Live, ..test_channel(9101) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-move"), - }) - .await - .expect("initial live stream should register"); - - manager.update_session_addr("user1", "tok-move", &new_addr).await; - - let connections = manager.connections.read().await; - assert!( - !connections.key_by_addr.contains_key(&old_addr), - "previous range-request socket registration should be pruned once the session moved" - ); - assert!(connections.key_by_addr.contains_key(&new_addr)); - - let connection_data = connections.by_key.get("user1").expect("user connection data"); - assert_eq!(connection_data.sessions.len(), 1); - assert_eq!(connection_data.sessions[0].addr, new_addr); - assert_eq!(connection_data.streams.len(), 1); - assert_eq!(connection_data.streams[0].addr, new_addr); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn vod_session_survives_overlapping_and_seek_sockets() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let base_addr: SocketAddr = "127.0.0.1:55131".parse().unwrap(); - let range_addr: SocketAddr = "127.0.0.1:55132".parse().unwrap(); - let seek_addr: SocketAddr = "127.0.0.1:55133".parse().unwrap(); - let base_fingerprint = Fingerprint::new("fp-vod-base".to_string(), "127.0.0.1".to_string(), base_addr); - let range_fingerprint = Fingerprint::new("fp-vod-range".to_string(), "127.0.0.1".to_string(), range_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user1"); - user.max_connections = 1; - - manager.add_connection(&base_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-vod", - virtual_id: 9102, - provider: "provider-a", - stream_url: "http://localhost/movie.mkv", - addr: &base_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 302, - meter_uid: 402, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &base_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::Video, ..test_channel(9102) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-vod"), - }) - .await - .expect("initial vod stream should register"); - - manager.add_connection(&range_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 303, - meter_uid: 403, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &range_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::Video, ..test_channel(9102) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-vod"), - }) - .await - .expect("overlapping range request should reuse the same vod session"); - - assert_eq!(manager.user_connections("user1").await, 1); - assert!(manager.release_stream(&range_addr).await.is_none()); - let released = manager.release_connection(&range_addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty()); - - { - let connections = manager.connections.read().await; - assert!(connections.key_by_addr.contains_key(&base_addr)); - let connection_data = connections.by_key.get("user1").expect("user connection data"); - assert_eq!(connection_data.sessions[0].addr, base_addr); - assert_eq!(connection_data.streams[0].addr, base_addr); - } - - manager.add_connection(&seek_addr).await; - manager.update_session_addr("user1", "tok-vod", &seek_addr).await; - - { - let connections = manager.connections.read().await; - assert!( - connections.key_by_addr.contains_key(&base_addr), - "existing vod socket must remain registered while the session spans multiple requests" - ); - assert!(connections.key_by_addr.contains_key(&seek_addr)); - - let connection_data = connections.by_key.get("user1").expect("user connection data"); - assert_eq!(connection_data.sessions[0].addr, seek_addr); - assert_eq!(connection_data.streams[0].addr, seek_addr); - } - - assert!(manager.release_stream(&seek_addr).await.is_none()); - let released = manager.release_connection(&seek_addr).await; - assert!(released.addr_removed); - assert!(released.removed_streams.is_empty()); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get("user1").expect("user connection data"); - assert_eq!(connection_data.sessions[0].addr, base_addr); - assert_eq!(connection_data.streams[0].addr, base_addr); - } - - #[tokio::test] - async fn catchup_release_connection_preserves_logical_stream_until_session_expires() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55141".parse().unwrap(); - let next_addr: SocketAddr = "127.0.0.1:55142".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-catchup-1".to_string(), "127.0.0.1".to_string(), addr); - let next_fingerprint = Fingerprint::new("fp-catchup-2".to_string(), "127.0.0.1".to_string(), next_addr); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-catchup"); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-catchup", - virtual_id: 9103, - provider: "provider-a", - stream_url: "http://localhost/archive.ts", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - let first = manager - .update_connection(ActiveUserConnectionParams { - uid: 304, - meter_uid: 404, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::Catchup, ..test_channel(9103) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-catchup"), - }) - .await - .expect("initial catchup stream should register"); - - let released = manager.release_connection(&addr).await; - assert!(released.addr_removed); - assert!( - released.removed_streams.is_empty(), - "catchup stream should remain logically active between range requests" - ); - - assert_eq!(manager.user_connections(&user.username).await, 0); - assert!(manager.active_streams().await.is_empty()); - - let connections = manager.connections.read().await; - let preserved_stream = connections - .by_key - .get(&user.username) - .and_then(|data| data.streams.iter().find(|stream| stream.uid == first.uid)) - .expect("preserved catchup stream should stay internally tracked"); - assert!(preserved_stream.preserved); - drop(connections); - - manager.add_connection(&next_addr).await; - let second = manager - .update_connection(ActiveUserConnectionParams { - uid: 305, - meter_uid: 405, - username: &user.username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &next_fingerprint, - provider: "provider-a".intern(), - stream_channel: &StreamChannel { item_type: PlaylistItemType::Catchup, ..test_channel(9103) }, - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-catchup"), - }) - .await - .expect("catchup stream should reconnect"); - - assert_eq!(second.uid, first.uid); - assert_eq!(second.started_at, first.started_at); - assert!(!second.preserved); - } - - #[tokio::test] - async fn gc_keeps_active_ts_streams_even_when_user_timestamp_is_stale() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55013".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key-ts".to_string(), "127.0.0.1".to_string(), addr); - - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 144, - meter_uid: 244, - username: "user1", - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(9001), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("ts stream should register"); - - { - let mut connections = manager.connections.write().await; - let connection_data = connections.by_key.get_mut("user1").expect("user entry should exist"); - connection_data.ts = connection_data.ts.saturating_sub(USER_CON_TTL + 1); - } - - if let Some(gc_ts) = &manager.gc_ts { - gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); - } - - manager.active_streams().await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get("user1").expect("active user entry must survive gc"); - assert_eq!(connection_data.connections, 1); - assert_eq!(connection_data.streams.len(), 1); - } - - #[tokio::test] - async fn session_activation_keeps_first_hls_slot_uncommitted_before_stream_registration() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-hls-reserve"); - user.max_connections = 1; - - let first_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap(); - let second_addr: SocketAddr = "127.0.0.1:55181".parse().unwrap(); - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-first", - virtual_id: 9201, - provider: "provider-a", - stream_url: "http://localhost/live-a.m3u8", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-second", - virtual_id: 9202, - provider: "provider-a", - stream_url: "http://localhost/live-b.m3u8", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let first_admission = manager - .connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-first") - .await; - let second_admission = manager - .connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-second") - .await; - - assert_eq!(first_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(first_admission.kind, Some(ConnectionKind::Normal)); - assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_eq!(connection_data.connections, 0); - assert_eq!(connection_data.counts.normal, 0); - assert_eq!(connection_data.streams.len(), 0); - assert!(connection_data - .sessions - .iter() - .find(|session| session.token == "tok-first") - .is_some_and(|session| !session.lifecycle.is_counted())); - assert!(connection_data - .sessions - .iter() - .find(|session| session.token == "tok-second") - .is_some_and(|session| !session.lifecycle.is_counted())); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn binding_reserved_sessions_keeps_hard_and_soft_counts_stable() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-hls-soft"); - user.max_connections = 1; - user.soft_connections = 1; - - let first_addr: SocketAddr = "127.0.0.1:55182".parse().unwrap(); - let second_addr: SocketAddr = "127.0.0.1:55183".parse().unwrap(); - let first_fingerprint = Fingerprint::new("fp-hls-1".to_string(), "127.0.0.1".to_string(), first_addr); - let second_fingerprint = Fingerprint::new("fp-hls-2".to_string(), "127.0.0.1".to_string(), second_addr); - - manager.add_connection(&first_addr).await; - manager.add_connection(&second_addr).await; - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-normal", - virtual_id: 9203, - provider: "provider-a", - stream_url: "http://localhost/live-normal.m3u8", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-soft", - virtual_id: 9204, - provider: "provider-a", - stream_url: "http://localhost/live-soft.m3u8", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let first_admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - "tok-normal", - ) - .await; - assert_eq!(first_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(first_admission.kind, Some(ConnectionKind::Normal)); - - manager - .update_connection(ActiveUserConnectionParams { - uid: 401, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &first_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9203), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-normal"), - }) - .await - .expect("reserved normal session should bind"); - - let second_admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - "tok-soft", - ) - .await; - assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(second_admission.kind, Some(ConnectionKind::Soft)); - - manager - .update_connection(ActiveUserConnectionParams { - uid: 402, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Soft, - priority: 0, - soft_priority: 0, - fingerprint: &second_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9204), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-soft"), - }) - .await - .expect("reserved soft session should bind"); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_eq!(connection_data.connections, 2); - assert_eq!(connection_data.counts.normal, 1); - assert_eq!(connection_data.counts.soft, 1); - assert_eq!(connection_data.streams.len(), 2); - assert_eq!( - connection_data.stream_kinds.get(&401), - Some(&ConnectionKind::Normal), - "binding a reserved normal session must not increment counts twice" - ); - assert_eq!( - connection_data.stream_kinds.get(&402), - Some(&ConnectionKind::Soft), - "binding a reserved soft session must keep the soft classification" - ); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn origin_policy_refresh_promotes_counted_soft_session_when_hard_slot_is_available() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-hls-policy-refresh"); - user.max_connections = 1; - user.soft_connections = 1; - - let normal_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap(); - let soft_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap(); - let normal_fingerprint = Fingerprint::new("fp-hls-policy-1".to_string(), "127.0.0.1".to_string(), normal_addr); - let soft_fingerprint = Fingerprint::new("fp-hls-policy-2".to_string(), "127.0.0.1".to_string(), soft_addr); - - manager.add_connection(&normal_addr).await; - manager.add_connection(&soft_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-normal", - virtual_id: 9210, - provider: "provider-a", - stream_url: "http://localhost/live-normal.m3u8", - addr: &normal_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-soft", - virtual_id: 9211, - provider: "provider-a", - stream_url: "http://localhost/live-soft.m3u8", - addr: &soft_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let normal_admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - "tok-normal", - ) - .await; - assert_eq!(normal_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(normal_admission.kind, Some(ConnectionKind::Normal)); - manager - .update_connection(ActiveUserConnectionParams { - uid: 411, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &normal_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9210), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-normal"), - }) - .await - .expect("normal stream should bind"); - - let soft_admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - "tok-soft", - ) - .await; - assert_eq!(soft_admission.permission, UserConnectionPermission::Allowed); - assert_eq!(soft_admission.kind, Some(ConnectionKind::Soft)); - manager - .update_connection(ActiveUserConnectionParams { - uid: 412, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Soft, - priority: 0, - soft_priority: 0, - fingerprint: &soft_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9211), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-soft"), - }) - .await - .expect("soft stream should bind"); - - assert!(manager.release_session_streams_and_counted_reservation(&user.username, "tok-normal").await); - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_eq!(connection_data.connections, 1); - assert_eq!(connection_data.counts.normal, 0); - assert_eq!(connection_data.counts.soft, 1); - assert_eq!( - connection_data - .sessions - .iter() - .find(|session| session.token == "tok-soft") - .and_then(|session| session.connection_kind), - Some(ConnectionKind::Soft) - ); - } - - let refreshed_kind = manager - .refresh_session_connection_kind_for_origin_policy( - &user.username, - user.max_connections, - user.soft_connections, - "tok-soft", - ) - .await; - assert_eq!(refreshed_kind, Some(ConnectionKind::Normal)); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_eq!(connection_data.connections, 1); - assert_eq!(connection_data.counts.normal, 1); - assert_eq!(connection_data.counts.soft, 0); - assert_eq!( - connection_data - .sessions - .iter() - .find(|session| session.token == "tok-soft") - .and_then(|session| session.connection_kind), - Some(ConnectionKind::Normal) - ); - assert_eq!(connection_data.stream_kinds.get(&412), Some(&ConnectionKind::Normal)); - } - - #[tokio::test] - async fn origin_policy_refresh_returns_none_for_pending_grace_without_available_slot() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-pending-grace-origin-policy"); - user.max_connections = 1; - - let active_addr: SocketAddr = "127.0.0.1:55195".parse().unwrap(); - let pending_addr: SocketAddr = "127.0.0.1:55196".parse().unwrap(); - let active_fingerprint = Fingerprint::new("active".to_string(), "127.0.0.1".to_string(), active_addr); - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-active", - virtual_id: 9301, - provider: "provider-a", - stream_url: "http://localhost/live-active.m3u8", - addr: &active_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 9301, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &active_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(9301), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-active"), - }) - .await - .expect("active stream should bind the only normal slot"); - - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-pending", - virtual_id: 9302, - provider: "provider-a", - stream_url: "http://localhost/live-pending.m3u8", - addr: &pending_addr, - connection_permission: UserConnectionPermission::GracePeriod, - connection_kind: None, - socket_bound: false, - }) - .await; - manager - .mark_pending_provider( - &user.username, - "tok-pending", - PendingProviderReason::GraceHold, - current_time_secs() + 30, - ) - .await - .expect("pending session should be marked"); - - let refreshed_kind = manager - .refresh_session_connection_kind_for_origin_policy( - &user.username, - user.max_connections, - user.soft_connections, - "tok-pending", - ) - .await; - - assert_eq!(refreshed_kind, None); - } - - #[tokio::test] - async fn release_unbound_session_reservation_frees_reserved_slot() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-release-reservation"); - user.max_connections = 1; - - let addr: SocketAddr = "127.0.0.1:55184".parse().unwrap(); - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-release", - virtual_id: 9205, - provider: "provider-a", - stream_url: "http://localhost/live-release.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let admission = manager - .connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-release") - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - - manager.release_unbound_session_reservation(&user.username, "tok-release", None, false).await; - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_eq!(connection_data.connections, 0); - assert_eq!(connection_data.counts.normal, 0); - assert_eq!(connection_data.streams.len(), 0); - assert!(connection_data - .sessions - .iter() - .find(|session| session.token == "tok-release") - .is_some_and(|session| !session.lifecycle.is_counted())); - } - - #[tokio::test] - async fn preserved_reactivation_admission_does_not_create_ownerless_counted_slot() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-activation", 1, 0); - let addr: SocketAddr = "127.0.0.1:55195".parse().unwrap(); - let session_token = "tok-preserved-activation"; - let stream_uid = 501; - - commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) - .await; - - let virtual_admission = - manager.connection_admission(&user.username, user.max_connections, user.soft_connections).await; - assert_eq!(virtual_admission.permission, UserConnectionPermission::Exhausted); - - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(admission.kind, Some(ConnectionKind::Normal)); - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); - assert_no_real_connection_slots(connection_data); - } - - #[tokio::test] - async fn preserved_reactivation_admission_then_kicked_release_removes_state_without_ghost_counter() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-eviction", 1, 0); - let addr: SocketAddr = "127.0.0.1:55201".parse().unwrap(); - let session_token = "tok-preserved-eviction"; - let stream_uid = 601; - - commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) - .await; - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); - assert_no_real_connection_slots(connection_data); - } - - let released = manager.release_connection_as_kicked(&addr).await; - assert!(released.addr_removed); - assert_eq!(released.removed_streams.len(), 1); - let removed_stream = released.removed_streams.first().expect("kicked release must remove the preserved stream"); - assert_eq!(removed_stream.uid, stream_uid); - assert!(removed_stream.preserved); - assert_eq!(removed_stream.session_token.as_deref(), Some(session_token)); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert!(connection_data.streams.iter().all(|stream| stream.uid != stream_uid)); - assert!(connection_data.sessions.iter().all(|session| session.token != session_token)); - assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); - assert_no_real_connection_slots(connection_data); - drop(connections); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - } - - #[tokio::test] - async fn preserved_reactivation_admission_then_lease_idle_cleanup_leaves_counters_at_zero() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-idle-cleanup", 1, 0); - let addr: SocketAddr = "127.0.0.1:55202".parse().unwrap(); - let session_token = "tok-preserved-idle-cleanup"; - let stream_uid = 602; - - commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) - .await; - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); - assert_no_real_connection_slots(connection_data); - } - - // Shared-HLS lease-idle cleanup delegates to this manager operation. - let counter_changed = - manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; - assert!(!counter_changed, "removing an uncounted preserved stream must not change real counters"); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert!(connection_data.streams.iter().all(|stream| stream.uid != stream_uid)); - assert!(connection_data - .sessions - .iter() - .find(|session| session.token == session_token) - .is_some_and(|session| session.lifecycle == PlaybackLifecycle::Preserved)); - assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); - assert_no_real_connection_slots(connection_data); - drop(connections); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - } - - #[tokio::test] - async fn repeated_preserved_reactivation_cleanup_does_not_accumulate_connections() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-repeat", 4, 0); - for (session_token, stream_uid, addr) in - [("tok-preserved-repeat-one", 603, "127.0.0.1:55203"), ("tok-preserved-repeat-two", 604, "127.0.0.1:55204")] - { - let addr = addr.parse().unwrap(); - commit_and_preserve_adaptive_session( - &manager, - &user, - session_token, - stream_uid, - addr, - ConnectionKind::Normal, - ) - .await; - - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - - let counter_changed = - manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; - assert!(!counter_changed, "cleanup must not release a slot that was never committed"); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_no_real_connection_slots(connection_data); - drop(connections); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - } - } - - #[tokio::test] - async fn dashboard_counts_only_real_slots_during_preserved_reactivation_admission() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-dashboard", 1, 0); - let addr: SocketAddr = "127.0.0.1:55205".parse().unwrap(); - let session_token = "tok-preserved-dashboard"; - let stream_uid = 605; - - commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) - .await; - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); - assert_no_real_connection_slots(connection_data); - } - - #[tokio::test] - async fn preserved_soft_reactivation_and_cleanup_leave_normal_slot_unchanged() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let user = test_user_credentials("user-preserved-soft", 1, 1); - let normal_addr: SocketAddr = "127.0.0.1:55206".parse().unwrap(); - let normal_fingerprint = - Fingerprint::new("fp-preserved-soft-normal".to_string(), normal_addr.ip().to_string(), normal_addr); - let soft_addr: SocketAddr = "127.0.0.1:55207".parse().unwrap(); - let normal_stream_uid = 606; - let session_token = "tok-preserved-soft"; - let soft_stream_uid = 607; - - manager.add_connection(&normal_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: normal_stream_uid, - meter_uid: 0, - username: &user.username, - max_connections: user.max_connections, - soft_connections: user.soft_connections, - connection_kind: ConnectionKind::Normal, - priority: user.priority, - soft_priority: user.soft_priority, - fingerprint: &normal_fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_channel(normal_stream_uid), - user_agent: Cow::Borrowed("ua"), - session_token: None, - }) - .await - .expect("normal stream should bind"); - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_single_normal_stream_slot(connection_data, normal_stream_uid); - } - - commit_and_preserve_adaptive_session( - &manager, - &user, - session_token, - soft_stream_uid, - soft_addr, - ConnectionKind::Soft, - ) - .await; - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, soft_stream_uid); - assert_single_normal_stream_slot(connection_data, normal_stream_uid); - } - - let admission = manager - .connection_admission_for_session_activation( - &user.username, - user.max_connections, - user.soft_connections, - session_token, - ) - .await; - assert_eq!(admission.permission, UserConnectionPermission::Allowed); - assert_eq!(admission.kind, Some(ConnectionKind::Soft)); - - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert_preserved_session_is_uncounted(connection_data, session_token, soft_stream_uid); - assert_single_normal_stream_slot(connection_data, normal_stream_uid); - } - - let counter_changed = - manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; - assert!(!counter_changed, "preserved soft cleanup must not release an uncommitted slot"); - { - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert!(connection_data.streams.iter().all(|stream| stream.uid != soft_stream_uid)); - assert!(!connection_data.stream_kinds.contains_key(&soft_stream_uid)); - assert!(connection_data - .sessions - .iter() - .find(|session| session.token == session_token) - .is_some_and(|session| session.lifecycle == PlaybackLifecycle::Preserved)); - assert_single_normal_stream_slot(connection_data, normal_stream_uid); - } - - manager.release_stream_by_uid(&normal_addr, normal_stream_uid).await.expect("normal stream should release"); - let connections = manager.connections.read().await; - let connection_data = connections.by_key.get(&user.username).expect("user connection data"); - assert!(connection_data.streams.is_empty()); - assert!(connection_data.stream_kinds.is_empty()); - assert_no_real_connection_slots(connection_data); - drop(connections); - assert_eq!(manager.active_users_and_connections().await, (0, 0)); - } - - #[tokio::test] - async fn release_unbound_session_reservation_ignores_stale_transition_version() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-stale-release"); - - let addr: SocketAddr = "127.0.0.1:55194".parse().unwrap(); - let stale_version = manager - .ensure_user_session_placeholder(CreateUserSessionParams { - user: &user, - session_token: "tok-stale-release", - virtual_id: 9206, - provider: "provider-a", - stream_url: "http://localhost/live-stale.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - let _ = manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-stale-release", - virtual_id: 9206, - provider: "provider-b", - stream_url: "http://localhost/live-stale-updated.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - manager - .release_unbound_session_reservation(&user.username, "tok-stale-release", Some(stale_version), true) - .await; - - let session = manager - .get_and_update_user_session(&user.username, "tok-stale-release") - .await - .expect("stale rollback must not remove the newer session"); - assert!(session.transition_version > stale_version); - assert_eq!(session.provider.as_ref(), "provider-b"); - assert_eq!(session.stream_url.as_ref(), "http://localhost/live-stale-updated.m3u8"); - } - - #[tokio::test] - async fn clear_unbound_session_addr_prunes_manifest_addr_without_stream() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let first_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap(); - let second_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = String::from("user-clear-addr"); - user.max_connections = 1; - - manager.add_connection(&first_addr).await; - manager.add_connection(&second_addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-clear-addr", - virtual_id: 9206, - provider: "provider-a", - stream_url: "http://localhost/live-clear.m3u8", - addr: &first_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-clear-addr", - virtual_id: 9206, - provider: "provider-a", - stream_url: "http://localhost/live-clear.m3u8", - addr: &second_addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - manager.clear_unbound_session_addr(&user.username, "tok-clear-addr", &second_addr).await; - - let connections = manager.connections.read().await; - let session = connections - .by_key - .get(&user.username) - .and_then(|connection_data| { - connection_data.sessions.iter().find(|session| session.token == "tok-clear-addr") - }) - .expect("session should remain"); - assert_eq!(session.addr, first_addr); - assert_eq!(session.active_addrs, vec![first_addr]); - } - - #[tokio::test] - async fn get_eviction_candidates_keeps_preserved_streams_evictable() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55300".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-key".to_string(), "192.168.1.100".to_string(), addr); - let username = "user-eviction-addr"; - let mut user = ProxyUserCredentials::default(); - user.username = username.to_string(); - user.max_connections = 1; - user.soft_connections = 0; - - // Create session first (HLS type = preserved after release) - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-preserved-1", - virtual_id: 5001, - provider: "provider-a", - stream_url: "http://localhost/live.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - - // Create stream + register connection - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 1, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(5001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-preserved-1"), - }) - .await - .expect("first stream"); - assert_eq!(manager.user_connections(username).await, 1); - - // Release -> stream becomes preserved, session becomes uncounted - manager.release_stream(&addr).await; - assert_eq!(manager.user_connections(username).await, 0, "preserved stream should not count"); - - let candidates = manager.get_eviction_candidates(username, "192.168.1.100").await; - assert!( - candidates.iter().any(|candidate| candidate.addr == addr), - "preserved stream should remain a direct eviction candidate" - ); - } - - #[tokio::test] - async fn get_eviction_candidates_does_not_count_preserved_streams_in_addr_counts() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55801".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-preserved-no-count".to_string(), "10.0.0.5".to_string(), addr); - let username = "user-preserved-addr-count"; - let mut user = ProxyUserCredentials::default(); - user.username = username.to_string(); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-preserved-addr-count", - virtual_id: 7000, - provider: "provider-preserved", - stream_url: "http://localhost/preserved.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 7000, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-preserved".intern(), - stream_channel: &test_adaptive_channel(7000), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-preserved-addr-count"), - }) - .await - .expect("stream should be created"); - - // Release -> stream becomes preserved, session becomes uncounted - manager.release_stream(&addr).await; - - // Preserved streams do not consume a counted slot — user_connections should be 0 - assert_eq!( - manager.user_connections(username).await, - 0, - "preserved stream should not count toward active connections" - ); - - // But the preserved stream is still a valid eviction candidate (valid victim) - let candidates = manager.get_eviction_candidates(username, "10.0.0.5").await; - assert!(candidates.iter().any(|c| c.addr == addr), "preserved stream should be an eviction candidate"); - } - - #[tokio::test] - async fn connection_admission_treats_preserved_stream_as_reserved_capacity() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55305".parse().unwrap(); - let fingerprint = Fingerprint::new("fp-preserved-admission".to_string(), "192.168.1.100".to_string(), addr); - let username = "user-preserved-admission"; - let mut user = ProxyUserCredentials::default(); - user.username = username.to_string(); - user.max_connections = 1; - - manager.add_connection(&addr).await; - manager - .create_user_session(CreateUserSessionParams { - user: &user, - session_token: "tok-preserved-admission", - virtual_id: 6000, - provider: "provider-a", - stream_url: "http://localhost/live-preserved.m3u8", - addr: &addr, - connection_permission: UserConnectionPermission::Allowed, - connection_kind: Some(ConnectionKind::Normal), - socket_bound: false, - }) - .await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 6000, - meter_uid: 0, - username, - max_connections: user.max_connections, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &fingerprint, - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(6000), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-preserved-admission"), - }) - .await - .expect("preserved stream should be created"); - - manager.release_connection(&addr).await; - assert_eq!( - manager.user_connections(username).await, - 0, - "preserved stream stays uncounted for active snapshots" - ); - - let admission = manager.connection_admission(username, user.max_connections, 0).await; - assert_eq!( - admission.permission, - UserConnectionPermission::Exhausted, - "a preserved stream should still reserve capacity against unrelated playback admissions" - ); - } - - #[tokio::test] - async fn connection_admission_for_session_evaluates_admission_for_uncounted_session() { - // Bug: connection_admission_for_session returns Allowed for any existing session, - // even if it's uncounted (preserved). This causes strategy evaluation to be skipped. - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let addr: SocketAddr = "127.0.0.1:55310".parse().unwrap(); - let username = "user-uncounted-admission"; - let mut user = ProxyUserCredentials::default(); - user.username = username.to_string(); - user.max_connections = 1; - user.soft_connections = 0; - - // Create session + counted stream (HLS type = preserved after release) - manager.add_connection(&addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 1, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &Fingerprint::new("fp".to_string(), "192.168.1.50".to_string(), addr), - provider: "provider-a".intern(), - stream_channel: &test_adaptive_channel(6001), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-uncounted"), - }) - .await - .expect("first stream"); - - // Release to preserve (uncounted session, but counts.normal still = 1 from the stream) - manager.release_stream(&addr).await; - // After preserve: session is uncounted, stream is preserved, connections=0 - // BUT the stream was removed, so counts.normal is decremented -> counts=0 - assert_eq!(manager.user_connections(username).await, 0); - - // Add a second stream first - this uses a different session token and consumes the slot - let second_addr: SocketAddr = "192.168.1.100:55311".parse().unwrap(); - manager.add_connection(&second_addr).await; - manager - .update_connection(ActiveUserConnectionParams { - uid: 2, - meter_uid: 0, - username, - max_connections: 1, - soft_connections: 0, - connection_kind: ConnectionKind::Normal, - priority: 0, - soft_priority: 0, - fingerprint: &Fingerprint::new("fp2".to_string(), "192.168.1.100".to_string(), second_addr), - provider: "provider-b".intern(), - stream_channel: &test_channel(6002), - user_agent: Cow::Borrowed("ua"), - session_token: Some("tok-second"), - }) - .await - .expect("second stream"); - // Now user is at limit: connections=1, counts.normal=1, max_connections=1 - assert_eq!(manager.user_connections(username).await, 1); - - // connection_admission_for_session for the PRESERVED session token should return - // Exhausted so that eviction strategies can run and evict the preserved stream, - // freeing a slot for the uncounted session to reactivate - let admission = manager.connection_admission_for_session(username, 1, 0, "tok-uncounted").await; - assert_eq!( - admission.permission, - UserConnectionPermission::Exhausted, - "uncounted session should not bypass admission when user is at limit; \ - bug: session exists -> Allowed -> strategy evaluation skipped" - ); - } - - #[tokio::test] - async fn playback_transition_gate_serializes_same_session() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - - let first_guard = manager.acquire_playback_transition("user-gated", "tok-gated").await; - let second_manager = Arc::clone(&manager); - let waiting = tokio::spawn(async move { - let _second_guard = second_manager.acquire_playback_transition("user-gated", "tok-gated").await; - }); - - tokio::time::sleep(Duration::from_millis(25)).await; - assert!( - !waiting.is_finished(), - "same-session transition gate should block a concurrent transition until the first completes" - ); - - drop(first_guard); - tokio::time::timeout(Duration::from_millis(100), waiting) - .await - .expect("second transition should proceed once the first guard is released") - .expect("second transition task should complete"); - } - - #[tokio::test] - async fn playback_transition_gate_cleanup_removes_idle_gates_on_next_acquire() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = ActiveUserManager::new(&config, &geoip, &event_manager); - - let first_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-first").await; - assert_eq!(manager.transition_gates.lock().await.len(), 1); - drop(first_guard); - - let second_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-second").await; - assert_eq!(manager.transition_gates.lock().await.len(), 1); - drop(second_guard); - } - - #[tokio::test] - async fn check_divergence_detects_connection_count_mismatch() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - - let addr: SocketAddr = "127.0.0.1:55902".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "div-user-2".to_string(); - - // Create a counted session without a stream or matching legacy counter. - { - let mut connections = manager.connections.write().await; - let data = - connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - data.add_session(UserSession { - token: "tok-div-2".to_string(), - transition_version: 1, - 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], - ts: current_time_secs(), - started_at: current_time_secs(), - permission: UserConnectionPermission::Allowed, - connection_kind: None, - lifecycle: PlaybackLifecycle::Active, - }); - } - - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user connection data"); - let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); - assert!(snapshot.kinds.contains(&DivergenceKind::CountedSessionWithoutStream)); - assert!(snapshot.kinds.contains(&DivergenceKind::ConnectionCountMismatch { legacy: 0, counted: 1 })); - drop(connections); - manager.log_divergence_snapshot(Some(snapshot)).await; - } - - #[tokio::test] - async fn check_divergence_detects_stream_without_counted_session() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - - let addr: SocketAddr = "127.0.0.1:55903".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "div-user-3".to_string(); - - { - let mut connections = manager.connections.write().await; - let data = - connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - - // Add a session with GraceHold pending — exempt from Invariant 1 - data.add_session(UserSession { - token: "tok-div-3".to_string(), - transition_version: 1, - 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], - ts: current_time_secs(), - started_at: current_time_secs(), - permission: UserConnectionPermission::Allowed, - connection_kind: None, - lifecycle: PlaybackLifecycle::PendingProvider { - data: PendingProviderState { - reason_code: PendingProviderReason::GraceHold, - created_at: current_time_secs(), - deadline: current_time_secs() + 30, - version: 1, - wake_source: None, - }, - }, - }); - data.increment_kind(ConnectionKind::Normal); - - // Add a stream whose session_token doesn't match any counted session - let orphan_stream = StreamInfo::new(shared::model::StreamInfoParams { - uid: 903, - meter_uid: 0, - username: &user.username, - addr: &addr, - client_ip: "127.0.0.1", - provider: "provider-a".intern(), - stream_channel: StreamChannel { - target_id: 1, - virtual_id: 9003, - provider_id: 1, - input_name: "provider-a".intern(), - item_type: PlaylistItemType::Live, - cluster: XtreamCluster::Live, - group: "g".intern(), - title: "t".intern(), - url: "http://localhost/stream.ts".intern(), - shared: false, - shared_joined_existing: None, - shared_stream_id: None, - technical: None, - epg_channel_id: None, - epg_reference_ts: None, - upstream_user_agent: None, - }, - user_agent: "ua".to_string(), - country_code: None, - session_token: Some("tok-orphan"), - }); - data.streams.push(orphan_stream); - data.stream_kinds.insert(903, ConnectionKind::Normal); - } - - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user connection data"); - let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); - assert!(snapshot.kinds.contains(&DivergenceKind::StreamWithoutCountedSession)); - drop(connections); - manager.log_divergence_snapshot(Some(snapshot)).await; - } - - #[tokio::test] - async fn divergence_log_rate_limited_within_cooldown_window() { - let config = Config::default(); - let geoip = Arc::new(ArcSwapOption::::default()); - let event_manager = Arc::new(EventManager::new()); - let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); - - let addr: SocketAddr = "127.0.0.1:55904".parse().unwrap(); - let mut user = ProxyUserCredentials::default(); - user.username = "div-user-4".to_string(); - - // Create mismatch - { - let mut connections = manager.connections.write().await; - let data = - connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - data.increment_kind(ConnectionKind::Normal); - data.add_session(UserSession { - token: "tok-div-4".to_string(), - transition_version: 1, - 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], - ts: current_time_secs(), - started_at: current_time_secs(), - permission: UserConnectionPermission::Allowed, - connection_kind: None, - lifecycle: PlaybackLifecycle::Prepared, - }); - } - - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user connection data"); - let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); - drop(connections); - manager.log_divergence_snapshot(Some(snapshot)).await; - let key = divergence_key(&user.username, &DivergenceKind::ConnectionCountMismatch { legacy: 1, counted: 0 }); - let first_logged = { - let cache = manager.divergence_cache.lock().await; - let entry = cache.peek(&key).expect("first divergence should populate the cache"); - assert_eq!(entry.count_since_last_log, 0); - entry.last_logged - }; - - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user connection data"); - let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); - drop(connections); - manager.log_divergence_snapshot(Some(snapshot)).await; - let connections = manager.connections.read().await; - let data = connections.by_key.get(&user.username).expect("user connection data"); - let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); - drop(connections); - manager.log_divergence_snapshot(Some(snapshot)).await; - let cache = manager.divergence_cache.lock().await; - let entry = cache.peek(&key).expect("repeated divergence should remain cached"); - assert_eq!(entry.count_since_last_log, 2); - assert_eq!(entry.last_logged, first_logged); - } -} - -// -// mod tests { -// use std::sync::{Arc, atomic::{AtomicUsize, Ordering}}; -// use std::time::Instant; -// use std::thread; -// -// fn benchmark(ordering: Ordering, iterations: usize) -> u128 { -// let counter = Arc::new(AtomicUsize::new(0)); -// let start = Instant::now(); -// -// let handles: Vec<_> = (0..32) -// .map(|_| { -// let counter_ref = Arc::clone(&counter); -// thread::spawn(move || { -// for _ in 0..iterations { -// counter_ref.fetch_add(1, ordering); -// } -// }) -// }) -// .collect(); -// - -// for handle in handles { -// handle.join().unwrap(); -// } -// -// let duration = start.elapsed(); -// duration.as_millis() -// } -// -// #[test] -// fn test_ordering() { -// let iterations = 1_000_000; -// -// let time_acqrel = benchmark(Ordering::SeqCst, iterations); -// println!("AcqRel: {} ms", time_acqrel); -// -// let time_seqcst = benchmark(Ordering::SeqCst, iterations); -// println!("SeqCst: {} ms", time_seqcst); -// } -// -// } diff --git a/backend/session/src/active_user_manager/mod.rs b/backend/session/src/active_user_manager/mod.rs new file mode 100644 index 000000000..bd4a1ac30 --- /dev/null +++ b/backend/session/src/active_user_manager/mod.rs @@ -0,0 +1,3389 @@ +use crate::{ + active_provider_manager::ConnectionKind, connection_manager::CleanupEvent, ActiveProviderManager, EventManager, +}; +use arc_swap::ArcSwapOption; +use jsonwebtoken::get_current_timestamp; +use log::{debug, info, log_enabled}; +use lru::LruCache; +use shared::{ + defaults::{ + default_grace_period_millis, default_grace_period_timeout_secs, default_hls_session_ttl_secs, DASH_EXT, HLS_EXT, + }, + model::{ + ActiveUserConnectionChange, CustomVideoStreamType, EventMessage, PlaylistItemType, StreamChannel, StreamInfo, + StreamTechnicalInfo, UserConnectionPermission, VirtualId, + }, + utils::{ + current_time_secs, extract_extension_from_url, is_catchup_session_token, sanitize_sensitive_info, strip_port, + Internable, + }, +}; +use std::{ + borrow::Cow, + cmp::Reverse, + collections::{BinaryHeap, HashMap, HashSet}, + net::SocketAddr, + num::NonZeroUsize, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; +use tokio::sync::{mpsc, Mutex, Notify, RwLock}; +use tokio_util::sync::CancellationToken; +use tuliprox_core::{ + model::{Config, Fingerprint, ProxyUserCredentials}, + utils::{debug_if_enabled, utc_day_from_secs}, +}; +use tuliprox_repository::GeoIp; + +/// Capacity of the per-user divergence cache. A constant so the conversion +/// cannot fail at runtime. +const DIVERGENCE_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(256).unwrap(); + +const USER_GC_TTL: u64 = 900; // 15 Min +const USER_CON_TTL: u64 = 1_800; // 30 minutes +const USER_SESSION_LIMIT: usize = 50; +const ANON_SOCKET_TTL: u64 = 300; // 5 Min +const DEFAULT_ACTIVE_SOCKET_TTL_SECS: u64 = 90; + +fn get_grace_options(config: &Config) -> (u64, u64) { + let (grace_period_millis, grace_period_timeout_secs) = + config.reverse_proxy.as_ref().and_then(|r| r.stream.as_ref()).map_or_else( + || (default_grace_period_millis(), default_grace_period_timeout_secs()), + |s| (s.grace_period_millis, s.grace_period_timeout_secs), + ); + (grace_period_millis, grace_period_timeout_secs) +} + +fn get_adaptive_session_ttl_secs(config: &Config) -> u64 { + config + .reverse_proxy + .as_ref() + .and_then(|r| r.stream.as_ref()) + .map_or_else(default_hls_session_ttl_secs, |s| s.hls_session_ttl_secs) +} + +fn stream_history_session_id(ts: u64, uid: u32) -> u64 { (ts << 32) | u64::from(uid) } + +fn decide_connection_kind( + counts: UserConnectionCounts, + max_connections: u32, + soft_connections: u16, +) -> Option { + if max_connections == 0 || counts.normal < max_connections { + return Some(ConnectionKind::Normal); + } + if soft_connections > 0 && counts.soft < soft_connections { + return Some(ConnectionKind::Soft); + } + None +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PendingProviderReason { + GraceHold, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PendingProviderWakeSource { + Activated, + Timeout, + CapacityNotify, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingProviderState { + pub reason_code: PendingProviderReason, + pub created_at: u64, + pub deadline: u64, + pub version: u64, + pub wake_source: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum PlaybackLifecycle { + #[default] + Prepared, + /// Waiting for a provider slot (`GraceMode::Hold`). The `data` field holds the pending state. + PendingProvider { + data: PendingProviderState, + }, + Active, + /// Provisional counted state for `GraceMode::Instant`. Counts against limits immediately + /// while the grace window resolves (success -> Active, failure -> Expired). + GraceActive, + Preserved, + Expired, +} + +impl PlaybackLifecycle { + /// Returns true for lifecycle states that own a counted admission lease. + /// Both `Active` and `GraceActive` count — `GraceActive` is a provisional + /// counted state for `GraceMode::Instant` sessions. + pub fn is_counted(&self) -> bool { matches!(self, Self::Active | Self::GraceActive) } +} + +#[derive(Clone, Debug)] +pub struct UserSession { + pub token: String, + pub transition_version: u64, + pub virtual_id: u32, + pub provider: Arc, + pub stream_url: Arc, + pub provider_session_headers: HashMap, + pub addr: SocketAddr, + pub socket_bound: bool, + pub active_addrs: Vec, + pub ts: u64, + pub started_at: u64, + pub permission: UserConnectionPermission, + pub connection_kind: Option, + pub lifecycle: PlaybackLifecycle, +} + +#[derive(Debug, Default, Clone, Copy)] +struct UserConnectionCounts { + normal: u32, + soft: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConnectionAdmission { + pub permission: UserConnectionPermission, + pub kind: Option, +} + +#[derive(Debug, Clone, Copy)] +struct PromotionAction { + addr: SocketAddr, + uid: u32, + new_priority: i8, +} + +#[derive(Debug)] +struct UserConnectionData { + max_connections: u32, + soft_connections: u16, + counts: UserConnectionCounts, + connections: u32, + granted_grace: bool, + grace_ts: u64, + sessions: Vec, + streams: Vec, + stream_kinds: HashMap, + stream_normal_priorities: HashMap, + ts: u64, +} + +impl UserConnectionData { + fn new(connections: u32, max_connections: u32, soft_connections: u16) -> Self { + Self { + max_connections, + soft_connections, + counts: UserConnectionCounts::default(), + connections, + granted_grace: false, + grace_ts: 0, + sessions: Vec::new(), + streams: Vec::new(), + stream_kinds: HashMap::new(), + stream_normal_priorities: HashMap::new(), + ts: current_time_secs(), + } + } + + fn add_session(&mut self, session: UserSession) { + self.gc(); + self.sessions.push(session); + } + fn gc(&mut self) { + if self.sessions.len() > USER_SESSION_LIMIT { + self.sessions.sort_by_key(|e| std::cmp::Reverse(e.ts)); + self.sessions.truncate(USER_SESSION_LIMIT); + } + } + + fn has_session_addr(&self, addr: &SocketAddr) -> bool { + self.sessions.iter().any(|session| session.addr == *addr || session.active_addrs.contains(addr)) + } + + fn release_addr_from_sessions(&mut self, addr: &SocketAddr) -> HashMap> { + let mut migrated_addrs = HashMap::new(); + for session in &mut self.sessions { + if session.addr == *addr || session.active_addrs.contains(addr) { + migrated_addrs.insert(session.token.clone(), release_session_addr(session, addr)); + } + } + migrated_addrs + } + + fn increment_kind(&mut self, kind: ConnectionKind) { + self.connections = self.connections.saturating_add(1); + match kind { + ConnectionKind::Normal => { + self.counts.normal = self.counts.normal.saturating_add(1); + } + ConnectionKind::Soft => { + self.counts.soft = self.counts.soft.saturating_add(1); + } + } + } + + fn decrement_kind(&mut self, kind: ConnectionKind) { + self.connections = self.connections.saturating_sub(1); + match kind { + ConnectionKind::Normal => { + self.counts.normal = self.counts.normal.saturating_sub(1); + } + ConnectionKind::Soft => { + self.counts.soft = self.counts.soft.saturating_sub(1); + } + } + } + + fn remove_streams_for_session_and_release_counted( + &mut self, + session_token: &str, + counted_kind: Option, + ) -> (u32, bool) { + let mut removed_count = 0; + let mut connection_changed = false; + let mut released_stream_kind = false; + let mut stream_idx = 0; + while stream_idx < self.streams.len() { + if self.streams[stream_idx].session_token.as_deref() != Some(session_token) { + stream_idx += 1; + continue; + } + + let uid = self.streams[stream_idx].uid; + if let Some(kind) = self.stream_kinds.remove(&uid) { + self.decrement_kind(kind); + released_stream_kind = true; + connection_changed = true; + } + self.stream_normal_priorities.remove(&uid); + self.streams.swap_remove(stream_idx); + removed_count += 1; + } + + if let Some(kind) = counted_kind.filter(|_| !released_stream_kind) { + self.decrement_kind(kind); + connection_changed = true; + } + + (removed_count, connection_changed) + } + + fn try_promote_soft_stream(&mut self) -> Option { + if self.counts.normal >= self.max_connections + || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) + { + return None; + } + + let candidate_uid = self + .streams + .iter() + .filter(|stream| !stream.preserved) + .filter_map(|stream| { + let kind = self.stream_kinds.get(&stream.uid).copied()?; + if kind != ConnectionKind::Soft { + return None; + } + let normal_priority = self.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default(); + Some((normal_priority, stream.ts, stream.uid, stream.addr)) + }) + .min_by_key(|(normal_priority, ts, uid, _)| (*normal_priority, *ts, *uid)); + + let (new_priority, _ts, uid, addr) = candidate_uid?; + + self.counts.normal = self.counts.normal.saturating_add(1); + if self.counts.soft > 0 { + self.counts.soft -= 1; + } + self.stream_kinds.insert(uid, ConnectionKind::Normal); + + Some(PromotionAction { addr, uid, new_priority }) + } + + fn try_promote_soft_session_reservation(&mut self) -> bool { + if self.counts.normal >= self.max_connections + || (u32::from(self.counts.soft)) <= u32::from(self.soft_connections) + { + return false; + } + + let active_tokens = + self.streams.iter().filter_map(|stream| stream.session_token.as_deref()).collect::>(); + + let candidate_index = self.sessions.iter().position(|session| { + session.lifecycle.is_counted() + && session.connection_kind == Some(ConnectionKind::Soft) + && !active_tokens.contains(session.token.as_str()) + }); + + let Some(candidate_index) = candidate_index else { + return false; + }; + + self.counts.normal = self.counts.normal.saturating_add(1); + if self.counts.soft > 0 { + self.counts.soft -= 1; + } + self.sessions[candidate_index].connection_kind = Some(ConnectionKind::Normal); + true + } + + fn effective_counts_for_admission(&self, exclude_session_token: Option<&str>) -> UserConnectionCounts { + let mut counts = self.counts; + let counted_tokens = self + .sessions + .iter() + .filter(|session| session.lifecycle.is_counted()) + .map(|session| session.token.as_str()) + .collect::>(); + let mut reserved_tokens = HashSet::new(); + + for stream in self.streams.iter().filter(|stream| stream.preserved) { + // Orphan preserved stream: no session token means no session to evict. + // Do not count it — it has no bearing on admission decisions. + let Some(session_token) = stream.session_token.as_deref() else { + continue; + }; + if exclude_session_token.is_some_and(|token| token == session_token) + || counted_tokens.contains(session_token) + || !reserved_tokens.insert(session_token) + { + continue; + } + + let kind = self + .sessions + .iter() + .find(|session| session.token == session_token) + .and_then(|session| session.connection_kind) + .unwrap_or(ConnectionKind::Normal); + match kind { + ConnectionKind::Normal => counts.normal = counts.normal.saturating_add(1), + ConnectionKind::Soft => counts.soft = counts.soft.saturating_add(1), + } + } + + counts + } +} + +fn create_socket_reentry_guard_key(username: &str, client_ip: &str, virtual_id: VirtualId) -> String { + shared::concat_string!(username, "|", client_ip, "|", &virtual_id.to_string()) +} + +fn is_stable_session_stream(stream: &StreamInfo) -> bool { + // Catchup-token Live/.ts segment sockets must preserve too; otherwise archive panel rows + // hard-remove every HLS chunk and Streams blinks even when frontend soft-preserve is present. + stream.channel.item_type == PlaylistItemType::Catchup + || stream.channel.item_type.is_live_adaptive() + || stream.session_token.as_deref().is_some_and(is_catchup_session_token) + || matches!( + extract_extension_from_url(stream.channel.url.as_ref()), + Some(ext) if ext == HLS_EXT || ext == DASH_EXT + ) +} + +fn uses_session_reentry_guard(stream: &StreamInfo) -> bool { + stream.channel.item_type.requires_provider_affinity() + || matches!( + extract_extension_from_url(stream.channel.url.as_ref()), + Some(ext) if ext == HLS_EXT || ext == DASH_EXT + ) +} + +#[derive(Clone, Copy, Debug)] +struct RecentWinnerProtection { + protected_addr: SocketAddr, + expires_at: u64, +} + +#[derive(Debug, Default)] +struct UserConnections { + kicked: HashMap, + recently_evicted_sessions: HashMap, + recent_socket_reentry_guards: HashMap, + by_key: HashMap, + key_by_addr: HashMap, +} + +#[derive(Clone, Debug)] +struct SocketRegistration { + username: String, + ts: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct AdaptiveExpiryEntry { + expires_at: u64, + username: String, + session_token: String, + uid: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +struct AdaptiveExpiryKey { + username: String, + session_token: String, + uid: u32, +} + +pub struct ReleasedConnection { + pub addr_removed: bool, + pub removed_streams: Vec, + pub disconnected_users: Vec, +} + +pub struct ActiveUserConnectionParams<'a> { + pub uid: u32, + pub meter_uid: u32, + pub username: &'a str, + pub max_connections: u32, + pub soft_connections: u16, + pub connection_kind: ConnectionKind, + pub priority: i8, + pub soft_priority: i8, + pub fingerprint: &'a Fingerprint, + pub provider: Arc, + pub stream_channel: &'a StreamChannel, + pub user_agent: Cow<'a, str>, + pub session_token: Option<&'a str>, +} + +pub struct CreateUserSessionParams<'a> { + pub user: &'a ProxyUserCredentials, + pub session_token: &'a str, + pub virtual_id: u32, + pub provider: &'a str, + pub stream_url: &'a str, + pub addr: &'a SocketAddr, + pub connection_permission: UserConnectionPermission, + pub connection_kind: Option, + pub socket_bound: bool, +} + +fn remember_session_addr(session: &mut UserSession, addr: SocketAddr) { + if session.socket_bound { + session.active_addrs.clear(); + } else if let Some(position) = session.active_addrs.iter().position(|active_addr| *active_addr == addr) { + session.active_addrs.remove(position); + } + session.active_addrs.push(addr); + session.addr = addr; +} + +fn release_session_addr(session: &mut UserSession, addr: &SocketAddr) -> Option { + if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) { + session.active_addrs.remove(position); + } else if session.addr != *addr { + return None; + } + + if session.addr == *addr { + if let Some(next_addr) = session.active_addrs.last().copied() { + session.addr = next_addr; + return Some(next_addr); + } + } + + None +} + +fn clear_session_addr(session: &mut UserSession, addr: &SocketAddr) -> bool { + let mut changed = false; + if let Some(position) = session.active_addrs.iter().position(|active_addr| active_addr == addr) { + session.active_addrs.remove(position); + changed = true; + } + + if session.addr == *addr { + if let Some(next_addr) = session.active_addrs.last().copied() { + session.addr = next_addr; + } else { + session.addr = SocketAddr::from(([0, 0, 0, 0], 0)); + } + changed = true; + } + + changed +} + +impl SocketRegistration { + fn anonymous() -> Self { Self { username: String::new(), ts: current_time_secs() } } +} + +struct UserSessionParams<'a> { + session_token: &'a str, + virtual_id: u32, + provider: &'a str, + stream_url: &'a str, + addr: &'a SocketAddr, + connection_permission: UserConnectionPermission, + connection_kind: Option, + socket_bound: bool, +} + +pub struct ActiveUserManager { + grace_period_millis: AtomicU64, + grace_period_timeout_secs: AtomicU64, + adaptive_session_ttl_secs: AtomicU64, + log_active_user: AtomicBool, + gc_ts: Option, + connections: RwLock, + adaptive_expiry_queue: Arc>>>, + adaptive_expiry_index: Arc>>, + adaptive_expiry_notify: Arc, + adaptive_expiry_cancel: CancellationToken, + adaptive_expiry_worker_started: AtomicBool, + event_manager: Arc, + geo_ip: Arc>, + last_logged_user_count: AtomicUsize, + last_logged_user_connection_count: AtomicUsize, + cleanup_tx: tokio::sync::OnceCell>, + provider_manager: tokio::sync::OnceCell>, + transition_gates: Mutex>>>, + pub dropped_cleanup_events: AtomicU64, + divergence_cache: Mutex>, + divergence_cooldown_secs: u64, +} + +struct DivergenceEntry { + last_logged: Instant, + count_since_last_log: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +enum DivergenceKind { + CountedSessionWithoutStream, + StreamWithoutCountedSession, + ConnectionCountMismatch { legacy: u32, counted: u32 }, +} + +fn divergence_key(username: &str, kind: &DivergenceKind) -> String { + match kind { + DivergenceKind::CountedSessionWithoutStream => format!("{username}:CountedSessionWithoutStream"), + DivergenceKind::StreamWithoutCountedSession => format!("{username}:StreamWithoutCountedSession"), + DivergenceKind::ConnectionCountMismatch { legacy, counted } => { + format!("{username}:ConnectionCountMismatch:{legacy}+{counted}") + } + } +} + +struct DivergenceSnapshot { + username: String, + connections: u32, + counted_sessions: usize, + streams_count: usize, + kinds: Vec, +} + +impl ActiveUserManager { + pub fn shutdown(&self) { self.adaptive_expiry_cancel.cancel(); } + + pub fn start_adaptive_expiry_worker(self: &Arc) { + if self + .adaptive_expiry_worker_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return; + } + + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.run_adaptive_expiry_worker().await; + }); + } + + fn lookup_country(&self, client_ip: &str) -> Option { + let geoip = self.geo_ip.load(); + (*geoip).as_ref().and_then(|geoip_db| geoip_db.lookup(&strip_port(client_ip))) + } + + fn custom_stream_technical_info() -> StreamTechnicalInfo { + StreamTechnicalInfo { + container: String::from("mpegts"), + resolution: String::new(), + fps: String::from("30"), + video_codec: String::from("H.264"), + audio_codec: String::from("AAC"), + audio_channels: String::from("Stereo"), + } + } + + /// The bus this manager publishes on. + /// + /// Exposed so the admission path can report a refusal without + /// `AdmissionCtx` growing a second handle to the same manager. + #[must_use] + pub fn events(&self) -> &Arc { &self.event_manager } + + pub fn new(config: &Config, geoip: &Arc>, event_manager: &Arc) -> Self { + let log_active_user: bool = config.log.as_ref().is_some_and(|l| l.log_active_user); + let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config); + + Self { + grace_period_millis: AtomicU64::new(grace_period_millis), + grace_period_timeout_secs: AtomicU64::new(grace_period_timeout_secs), + adaptive_session_ttl_secs: AtomicU64::new(get_adaptive_session_ttl_secs(config)), + log_active_user: AtomicBool::new(log_active_user), + connections: RwLock::new(UserConnections::default()), + adaptive_expiry_queue: Arc::new(Mutex::new(BinaryHeap::new())), + adaptive_expiry_index: Arc::new(Mutex::new(HashMap::new())), + adaptive_expiry_notify: Arc::new(Notify::new()), + adaptive_expiry_cancel: CancellationToken::new(), + adaptive_expiry_worker_started: AtomicBool::new(false), + gc_ts: Some(AtomicU64::new(current_time_secs())), + geo_ip: Arc::clone(geoip), + event_manager: Arc::clone(event_manager), + last_logged_user_count: AtomicUsize::new(0), + last_logged_user_connection_count: AtomicUsize::new(0), + cleanup_tx: tokio::sync::OnceCell::new(), + provider_manager: tokio::sync::OnceCell::new(), + transition_gates: Mutex::new(HashMap::new()), + dropped_cleanup_events: AtomicU64::new(0), + divergence_cache: Mutex::new(LruCache::new(DIVERGENCE_CACHE_CAPACITY)), + divergence_cooldown_secs: 300, + } + } + + fn transition_gate_key(username: &str, token: &str) -> String { + let mut key = String::with_capacity(username.len() + token.len() + 1); + key.push_str(username); + key.push('\0'); + key.push_str(token); + 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>>) { + transition_gates.retain(|_, gate| Arc::strong_count(gate) > 1); + } + + pub async fn acquire_playback_transition(&self, username: &str, token: &str) -> tokio::sync::OwnedMutexGuard<()> { + let key = Self::transition_gate_key(username, token); + 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 + } + + pub 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() + } + + pub fn set_cleanup_sender(&self, tx: mpsc::Sender) { let _ = self.cleanup_tx.set(tx); } + + pub fn set_provider_manager(&self, provider_manager: Arc) { + let _ = self.provider_manager.set(provider_manager); + } + + /// Collect a snapshot of all currently active streams for shutdown history recording. + pub async fn get_all_active_streams(&self) -> Vec { + let connections = self.connections.read().await; + connections + .by_key + .values() + .flat_map(|data| data.streams.iter().filter(|stream| !stream.preserved).cloned()) + .collect() + } + + async fn log_active_user(&self) { + let is_log_user_enabled = self.is_log_user_enabled(); + let (user_count, user_connection_count) = { self.active_users_and_connections().await }; + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connections( + user_count, + user_connection_count, + ))); + if !is_log_user_enabled { + return; + } + let last_user_count = self.last_logged_user_count.load(Ordering::Relaxed); + let last_connection_count = self.last_logged_user_connection_count.load(Ordering::Relaxed); + if last_user_count != user_count || last_connection_count != user_connection_count { + self.last_logged_user_count.store(user_count, Ordering::Relaxed); + self.last_logged_user_connection_count.store(user_connection_count, Ordering::Relaxed); + info!("Active Users: {user_count}, Active User Connections: {user_connection_count}"); + } + } + + async fn emit_promotion_update(&self, username: &str, action: PromotionAction) { + if let Some(provider_manager) = self.provider_manager.get() { + provider_manager.reclassify_connection(&action.addr, ConnectionKind::Normal, action.new_priority).await; + } + + let maybe_stream = { + let user_connections = self.connections.read().await; + user_connections.by_key.get(username).and_then(|connection_data| { + connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned() + }) + }; + if let Some(stream_info) = maybe_stream { + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); + } + } + + /// Releases an active stream for the given socket address without removing the + /// socket registration (`key_by_addr`). This is used when a stream ends while + /// the underlying HTTP connection may still remain open. + #[allow(clippy::too_many_lines)] + pub async fn release_stream(&self, addr: &SocketAddr) -> Option { + self.release_stream_inner(addr, None).await + } + + #[allow(clippy::too_many_lines)] + pub async fn release_stream_by_uid(&self, addr: &SocketAddr, stream_uid: u32) -> Option { + self.release_stream_inner(addr, Some(stream_uid)).await + } + + #[allow(clippy::too_many_lines)] + async fn release_stream_inner(&self, addr: &SocketAddr, stream_uid: Option) -> Option { + let ( + removed_stream, + username, + expiry_entry, + preserved_update, + connection_changed, + promotion, + divergence_snapshot, + ) = { + let mut user_connections = self.connections.write().await; + + let username = match stream_uid { + Some(uid) => user_connections.by_key.iter().find_map(|(username, connection_data)| { + connection_data + .streams + .iter() + .any(|stream| !stream.preserved && stream.uid == uid && stream.addr == *addr) + .then(|| username.clone()) + }), + None => user_connections + .key_by_addr + .get(addr) + .filter(|reg| !reg.username.is_empty()) + .map(|reg| reg.username.clone()) + .or_else(|| { + user_connections.by_key.iter().find_map(|(username, connection_data)| { + connection_data + .streams + .iter() + .any(|stream| !stream.preserved && stream.addr == *addr) + .then(|| username.clone()) + }) + }), + }?; + + let mut removed_stream = None; + let mut expiry_entry = None; + let mut preserved_update = None; + let mut connection_changed = false; + let mut promotion = None; + if let Some(connection_data) = user_connections.by_key.get_mut(&username) { + let migrated_session_addrs = connection_data.release_addr_from_sessions(addr); + if let Some(stream_idx) = connection_data.streams.iter().position(|stream| { + !stream.preserved + && stream_uid.map_or(stream.addr == *addr, |uid| stream.uid == uid && stream.addr == *addr) + }) { + let migrated_addr = connection_data.streams[stream_idx] + .session_token + .as_deref() + .and_then(|token| migrated_session_addrs.get(token)) + .copied() + .flatten(); + if let Some(next_addr) = migrated_addr { + connection_data.streams[stream_idx].addr = next_addr; + connection_data.streams[stream_idx].ts = current_time_secs(); + } else if Self::should_preserve_session_stream(&connection_data.streams[stream_idx]) { + let preserved_session_token = connection_data.streams[stream_idx].session_token.clone(); + if let Some(entry) = self.build_preserved_stream_expiry( + &username, + &connection_data.streams[stream_idx], + &connection_data.sessions, + ) { + if let Some(kind) = + connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) + { + connection_data.decrement_kind(kind); + connection_changed = true; + } + connection_data.stream_normal_priorities.remove(&connection_data.streams[stream_idx].uid); + if let Some(session_token) = preserved_session_token.as_deref() { + Self::clear_session_counted(connection_data, session_token); + } + connection_data.streams[stream_idx].preserved = true; + preserved_update = Some(connection_data.streams[stream_idx].clone()); + expiry_entry = Some(entry); + } else { + removed_stream = Some(connection_data.streams.swap_remove(stream_idx)); + } + } else { + removed_stream = Some(connection_data.streams.swap_remove(stream_idx)); + } + if let Some(removed_stream) = removed_stream.as_ref() { + if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) { + connection_data.decrement_kind(kind); + } + connection_data.stream_normal_priorities.remove(&removed_stream.uid); + connection_changed = true; + } + if connection_data.connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + if removed_stream.is_some() { + if let Some(action) = connection_data.try_promote_soft_stream() { + let promoted_stream = + connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); + if let Some(stream) = promoted_stream.as_ref() { + Self::promote_session_for_stream(connection_data, stream); + } + promotion = Some(action); + } + if let Some(session_token) = + removed_stream.as_ref().and_then(|stream| stream.session_token.as_deref()) + { + Self::clear_session_counted_without_stream(connection_data, session_token); + } + while connection_data.try_promote_soft_session_reservation() {} + } + } + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); + ( + removed_stream, + username, + expiry_entry, + preserved_update, + connection_changed, + promotion, + divergence_snapshot, + ) + } else { + (None, username, None, None, false, None, None) + } + }; + + self.log_divergence_snapshot(divergence_snapshot).await; + + if let Some(entry) = expiry_entry { + self.enqueue_adaptive_expiry(entry).await; + } + + if let Some(stream_info) = preserved_update { + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); + } + + if connection_changed { + if !username.is_empty() { + debug_if_enabled!( + "Released stream for user {username} at {}", + sanitize_sensitive_info(&addr.to_string()) + ); + } + self.log_active_user().await; + } + + if let Some(action) = promotion { + self.emit_promotion_update(&username, action).await; + } + + removed_stream + } + + #[allow(clippy::too_many_lines)] + async fn release_connection_inner(&self, addr: &SocketAddr, preserve_session_streams: bool) -> ReleasedConnection { + let ( + addr_removed, + connection_count_changed, + disconnected_users, + removed_streams, + expiry_entries, + preserved_updates, + promotions, + ) = { + let mut user_connections = self.connections.write().await; + + let registration = user_connections.key_by_addr.remove(addr); + let had_registration = registration.is_some(); + let mut disconnected_users = registration + .map(|registration| registration.username) + .filter(|username| !username.is_empty()) + .into_iter() + .collect::>(); + disconnected_users.extend( + user_connections + .by_key + .iter() + .filter(|(_, connection_data)| { + connection_data.has_session_addr(addr) + || connection_data.streams.iter().any(|stream| stream.addr == *addr) + }) + .map(|(username, _)| username.clone()), + ); + disconnected_users.sort_unstable(); + disconnected_users.dedup(); + + let mut removed_streams = Vec::new(); + let mut expiry_entries = Vec::new(); + let mut preserved_updates = Vec::new(); + let mut promotions = Vec::new(); + let mut connection_count_changed = false; + for username in &disconnected_users { + if let Some(connection_data) = user_connections.by_key.get_mut(username) { + let previous_connection_count = connection_data.connections; + let migrated_session_addrs = connection_data.release_addr_from_sessions(addr); + let mut remaining_streams = Vec::with_capacity(connection_data.streams.len()); + let mut released_kinds = Vec::new(); + let mut removed_session_tokens = HashSet::new(); + let mut preserved_session_tokens = Vec::new(); + let now = current_time_secs(); + for mut stream_info in connection_data.streams.drain(..) { + if stream_info.addr == *addr { + let migrated_addr = stream_info + .session_token + .as_deref() + .and_then(|token| migrated_session_addrs.get(token)) + .copied() + .flatten(); + if let Some(next_addr) = migrated_addr { + stream_info.addr = next_addr; + stream_info.ts = now; + remaining_streams.push(stream_info); + } else if preserve_session_streams && Self::should_preserve_session_stream(&stream_info) { + if let Some(entry) = self.build_preserved_stream_expiry( + username, + &stream_info, + &connection_data.sessions, + ) { + if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { + released_kinds.push(kind); + } + connection_data.stream_normal_priorities.remove(&stream_info.uid); + if let Some(token) = stream_info.session_token.as_ref() { + preserved_session_tokens.push(token.clone()); + } + if !stream_info.preserved { + stream_info.preserved = true; + preserved_updates.push(stream_info.clone()); + } + expiry_entries.push(entry); + remaining_streams.push(stream_info); + } else { + if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { + released_kinds.push(kind); + } + connection_data.stream_normal_priorities.remove(&stream_info.uid); + if let Some(token) = stream_info.session_token.as_ref() { + removed_session_tokens.insert(token.clone()); + } + removed_streams.push(stream_info); + } + } else { + if let Some(kind) = connection_data.stream_kinds.remove(&stream_info.uid) { + released_kinds.push(kind); + } + connection_data.stream_normal_priorities.remove(&stream_info.uid); + if let Some(token) = stream_info.session_token.as_ref() { + removed_session_tokens.insert(token.clone()); + } + removed_streams.push(stream_info); + } + } else { + remaining_streams.push(stream_info); + } + } + connection_data.streams = remaining_streams; + if !preserve_session_streams && !removed_session_tokens.is_empty() { + connection_data.sessions.retain(|session| !removed_session_tokens.contains(&session.token)); + } + for kind in released_kinds { + connection_data.decrement_kind(kind); + } + while let Some(action) = connection_data.try_promote_soft_stream() { + let promoted_stream = + connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); + if let Some(stream) = promoted_stream.as_ref() { + Self::promote_session_for_stream(connection_data, stream); + } + promotions.push((username.clone(), action)); + } + for session_token in &removed_session_tokens { + Self::clear_session_counted_without_stream(connection_data, session_token); + } + for session_token in &preserved_session_tokens { + Self::clear_session_counted(connection_data, session_token); + } + while connection_data.try_promote_soft_session_reservation() {} + + if connection_data.connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + connection_count_changed |= connection_data.connections != previous_connection_count; + } + } + let state_changed = had_registration || !disconnected_users.is_empty(); + ( + state_changed, + connection_count_changed, + disconnected_users, + removed_streams, + expiry_entries, + preserved_updates, + promotions, + ) + }; + + for entry in expiry_entries { + self.enqueue_adaptive_expiry(entry).await; + } + + for stream_info in preserved_updates { + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); + } + + for username in &disconnected_users { + if !username.is_empty() { + debug_if_enabled!( + "Released connection for user {username} at {}", + sanitize_sensitive_info(&addr.to_string()) + ); + } + } + if connection_count_changed { + self.log_active_user().await; + } + if addr_removed { + for (username, action) in promotions { + self.emit_promotion_update(&username, action).await; + } + } + + ReleasedConnection { addr_removed, removed_streams, disconnected_users } + } + + pub async fn release_connection(&self, addr: &SocketAddr) -> ReleasedConnection { + let released = self.release_connection_inner(addr, true).await; + // divergence check after connection release + if released.addr_removed { + for username in &released.disconnected_users { + self.check_and_log_divergence_for_user(username).await; + } + } + released + } + + pub async fn release_connection_as_kicked(&self, addr: &SocketAddr) -> ReleasedConnection { + let released = self.release_connection_inner(addr, false).await; + // divergence check after connection release + if released.addr_removed { + for username in &released.disconnected_users { + self.check_and_log_divergence_for_user(username).await; + } + } + released + } + + pub fn update_config(&self, config: &Config) { + let log_active_user = config.log.as_ref().is_some_and(|l| l.log_active_user); + let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config); + self.grace_period_millis.store(grace_period_millis, Ordering::Relaxed); + self.grace_period_timeout_secs.store(grace_period_timeout_secs, Ordering::Relaxed); + self.adaptive_session_ttl_secs.store(get_adaptive_session_ttl_secs(config), Ordering::Relaxed); + self.log_active_user.store(log_active_user, Ordering::Relaxed); + } + + pub async fn user_connections(&self, username: &str) -> u32 { + if let Some(connection_data) = self.connections.read().await.by_key.get(username) { + return connection_data.connections; + } + 0 + } + + fn check_connection_admission_with_counts( + &self, + username: &str, + connection_data: &mut UserConnectionData, + counts: UserConnectionCounts, + ) -> ConnectionAdmission { + let selected_kind = + decide_connection_kind(counts, connection_data.max_connections, connection_data.soft_connections); + let effective_connections = counts.normal.saturating_add(u32::from(counts.soft)); + + if let Some(kind) = selected_kind { + // Reset grace only once the user is back below the hard limit. + if effective_connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + return ConnectionAdmission { permission: UserConnectionPermission::Allowed, kind: Some(kind) }; + } + + let now = get_current_timestamp(); + // Check if user already used a grace period + if connection_data.granted_grace { + if effective_connections >= connection_data.max_connections + && now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed) + { + // Grace timeout, still active, deny connection + debug!("User access denied, grace exhausted, too many connections: {username}"); + return ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: None }; + } + // Grace timeout expired, reset grace counters + if effective_connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + } + + debug!("User access denied, too many connections: {username}"); + ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: None } + } + + fn check_connection_admission( + &self, + username: &str, + connection_data: &mut UserConnectionData, + ) -> ConnectionAdmission { + self.check_connection_admission_with_counts( + username, + connection_data, + connection_data.effective_counts_for_admission(None), + ) + } + + pub async fn connection_admission( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + ) -> ConnectionAdmission { + if max_connections > 0 || soft_connections > 0 { + if let Some(connection_data) = self.connections.write().await.by_key.get_mut(username) { + connection_data.max_connections = max_connections; + connection_data.soft_connections = soft_connections; + return self.check_connection_admission(username, connection_data); + } + } + ConnectionAdmission { permission: UserConnectionPermission::Allowed, kind: Some(ConnectionKind::Normal) } + } + + pub async fn connection_permission( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + ) -> UserConnectionPermission { + self.connection_admission(username, max_connections, soft_connections).await.permission + } + + pub async fn connection_admission_for_session( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + session_token: &str, + ) -> ConnectionAdmission { + if max_connections == 0 && soft_connections == 0 { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: Some(ConnectionKind::Normal), + }; + } + + let mut connections = self.connections.write().await; + let Some(connection_data) = connections.by_key.get_mut(username) else { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: Some(ConnectionKind::Normal), + }; + }; + connection_data.max_connections = max_connections; + connection_data.soft_connections = soft_connections; + + let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) + else { + return self.check_connection_admission(username, connection_data); + }; + + if connection_data.sessions[session_index].lifecycle.is_counted() { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: connection_data.sessions[session_index].connection_kind.or(Some(ConnectionKind::Normal)), + }; + } + + self.check_connection_admission_with_counts( + username, + connection_data, + connection_data.effective_counts_for_admission(Some(session_token)), + ) + } + + pub async fn connection_permission_for_session( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + session_token: &str, + ) -> UserConnectionPermission { + self.connection_admission_for_session(username, max_connections, soft_connections, session_token) + .await + .permission + } + + pub async fn refresh_session_connection_kind_for_origin_policy( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + session_token: &str, + ) -> Option { + if max_connections == 0 && soft_connections == 0 { + return Some(ConnectionKind::Normal); + } + + let (connection_kind, promotions, divergence_snapshot) = { + let mut connections = self.connections.write().await; + let connection_data = connections.by_key.get_mut(username)?; + connection_data.max_connections = max_connections; + connection_data.soft_connections = soft_connections; + + let session_index = connection_data.sessions.iter().position(|session| session.token == session_token)?; + + let promotions = Self::promote_counted_soft_session_to_normal_if_available(connection_data, session_token); + let connection_kind = if connection_data.sessions[session_index].lifecycle.is_counted() + || Self::session_has_stream(connection_data, session_token) + { + Some(connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)) + } else { + let admission = self.check_connection_admission_with_counts( + username, + connection_data, + connection_data.effective_counts_for_admission(Some(session_token)), + ); + if admission.permission == UserConnectionPermission::Allowed { + if let Some(kind) = admission.kind { + Self::update_session_admission( + &mut connection_data.sessions[session_index], + admission.permission, + Some(kind), + ); + } + admission.kind + } else { + None + } + }; + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + + (connection_kind, promotions, divergence_snapshot) + }; + + self.log_divergence_snapshot(divergence_snapshot).await; + for action in promotions { + self.emit_promotion_update(username, action).await; + } + + connection_kind + } + + pub async fn get_eviction_candidates(&self, username: &str, _client_ip: &str) -> Vec { + let connections = self.connections.read().await; + let Some(connection_data) = connections.by_key.get(username) else { + return Vec::new(); + }; + let mut addr_counts = HashMap::new(); + for stream in &connection_data.streams { + // Preserved streams do not occupy a counted slot — exclude from addr counts. + // They are still valid eviction candidates (see filter below), but they don't + // consume connection capacity, so they don't contribute to the "singleton addr" logic. + let contributes_to_count = if stream.preserved { + false + } else if let Some(token) = stream.session_token.as_deref() { + connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()) + } else { + true // orphan streams without a session are counted + }; + if contributes_to_count { + addr_counts + .entry(stream.addr) + .and_modify(|count: &mut u8| *count = count.saturating_add(1)) + .or_insert(1_u8); + } + } + let candidates: Vec<_> = connection_data + .streams + .iter() + .filter(|stream| { + if let Some(token) = stream.session_token.as_deref() { + connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()) + || stream.preserved + } else { + true + } + }) + .filter(|stream| { + let addr_count = addr_counts.get(&stream.addr).copied().unwrap_or(0); + if stream.preserved { + // Preserved streams are always valid eviction candidates — they hold no counted + // slot. addr_count is 0 for preserved-only addresses, 1+ for addresses with + // counted competition. Either way they can be evicted. + true + } else { + // Non-preserved streams: only on singleton counted addresses. + // addr_count 0 = no counted streams at this address (shouldn't happen since + // non-preserved streams aren't preserved, but addr_count would be >= 1). + // addr_count 1 = single counted stream at address — candidate. + // addr_count > 1 = multiple counted streams — not a singleton, not candidate. + addr_count == 1 + } + }) + .map(|s| crate::EvictionCandidate { addr: s.addr, client_ip: s.client_ip.clone(), ts: s.ts }) + .collect(); + candidates + } + + pub async fn grant_grace(&self, username: &str) -> bool { + if self.grace_period_millis.load(Ordering::Relaxed) == 0 { + debug!("Grace grant denied, grace_period_millis is zero for {username}"); + return false; + } + let mut connections = self.connections.write().await; + if let Some(connection_data) = connections.by_key.get_mut(username) { + let now = get_current_timestamp(); + if connection_data.connections < connection_data.max_connections { + debug!( + "Grace grant denied for {username}, user not at connection limit ({}/{})", + connection_data.connections, connection_data.max_connections + ); + return false; + } + if connection_data.granted_grace + && connection_data.connections >= connection_data.max_connections + && now - connection_data.grace_ts <= self.grace_period_timeout_secs.load(Ordering::Relaxed) + { + debug!("Grace grant denied, still within active grace timeout for {username}"); + return false; + } + connection_data.granted_grace = true; + connection_data.grace_ts = now; + debug!("Granted a grace period for user access: {username}"); + return true; + } + false + } + + pub async fn active_users_and_connections(&self) -> (usize, usize) { + self.gc(); + let user_connections = self.connections.read().await; + user_connections + .by_key + .values() + .filter_map(|c| { + let effective = c.connections as usize; + if effective > 0 { + Some(effective) + } else { + None + } + }) + .fold((0usize, 0usize), |(user_count, conn_count), effective| (user_count + 1, conn_count + effective)) + } + + pub async fn update_stream_detail( + &self, + addr: &SocketAddr, + video_type: CustomVideoStreamType, + ) -> Option { + let mut user_connections = self.connections.write().await; + let username = { + match user_connections.key_by_addr.get(addr) { + Some(registration) => registration.username.clone(), + None => return None, + } + }; + if let Some(connection_data) = user_connections.by_key.get_mut(&username) { + for stream in &mut connection_data.streams { + if &stream.addr == addr { + // IMPORTANT: `resolve_disconnect_reason` in connection_manager.rs parses + // `channel.title` back via `CustomVideoStreamType::from_str` to determine QoS + // disconnect reasons. If these values change, update that function too. + stream.provider = "tuliprox".intern(); + stream.channel.title = video_type.to_string().into(); + stream.channel.group = "".intern(); + stream.channel.technical = Some(Self::custom_stream_technical_info()); + return Some(stream.clone()); + } + } + } + None + } + + pub async fn add_connection(&self, addr: &SocketAddr) { + self.gc(); + let mut user_connections = self.connections.write().await; + user_connections + .key_by_addr + .entry(*addr) + .and_modify(|registration| registration.ts = current_time_secs()) + .or_insert_with(SocketRegistration::anonymous); + } + + #[allow(clippy::too_many_lines)] + pub async fn update_connection(&self, update: ActiveUserConnectionParams<'_>) -> Option { + let ActiveUserConnectionParams { + uid, + meter_uid, + username, + max_connections, + soft_connections, + connection_kind, + priority, + soft_priority: _, + fingerprint, + provider, + stream_channel, + user_agent, + session_token, + } = update; + let (stream_info, divergence_snapshot, connection_count_changed) = { + let mut user_connections = self.connections.write().await; + + let now = current_time_secs(); + if let Some(registration) = user_connections.key_by_addr.get_mut(&fingerprint.addr) { + registration.username = username.to_string(); + registration.ts = now; + } else { + user_connections + .key_by_addr + .insert(fingerprint.addr, SocketRegistration { username: username.to_string(), ts: now }); + } + + let tracked_socket_count = user_connections.key_by_addr.len(); + let connection_data = user_connections + .by_key + .entry(username.to_string()) + .or_insert_with(|| UserConnectionData::new(0, max_connections, soft_connections)); + connection_data.max_connections = max_connections; + connection_data.soft_connections = soft_connections; + let previous_connection_count = connection_data.connections; + + if let Some(token) = session_token { + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + session.ts = now; + remember_session_addr(session, fingerprint.addr); + Self::bump_session_transition_version(session); + } + } + + let user_agent_string = user_agent.to_string(); + let reserved_session_kind = session_token.and_then(|token| { + connection_data + .sessions + .iter() + .find(|session| session.token == token && session.lifecycle.is_counted()) + .map(|session| session.connection_kind.unwrap_or(connection_kind)) + }); + + let existing_stream_info = connection_data + .streams + .iter() + .position(|stream_info| match session_token { + Some(token) => { + stream_info.session_token.as_deref() == Some(token) + && Self::should_reuse_stream_for_session(stream_info, stream_channel) + } + None => stream_info.addr == fingerprint.addr && stream_info.session_token.is_none(), + }) + .map(|stream_idx| { + let session_started_at = session_token.and_then(|token| { + connection_data.sessions.iter().find(|s| s.token == token).map(|s| s.started_at) + }); + + let stream_info = &mut connection_data.streams[stream_idx]; + let client_ip = fingerprint.client_ip.clone(); + let preserve_started_at = stream_info.session_token.is_some() + && (stream_info.channel.item_type.is_live_adaptive() + || stream_channel.item_type.is_live_adaptive()); + let was_preserved = stream_info.preserved; + let old_session_id = stream_history_session_id(stream_info.ts, stream_info.uid); + stream_info.meter_uid = meter_uid; + stream_info.addr = fingerprint.addr; + stream_info.client_ip.clone_from(&client_ip); + stream_info.country_code = self.lookup_country(&client_ip); + stream_info.channel = stream_channel.clone(); + stream_info.provider = provider.clone(); + stream_info.user_agent.clone_from(&user_agent_string); + + if let Some(started_at) = session_started_at { + stream_info.started_at = started_at; + } + + if preserve_started_at { + let now = current_time_secs(); + if utc_day_from_secs(stream_info.ts) != utc_day_from_secs(now) { + stream_info.ts = now; + stream_info.previous_session_id = Some(old_session_id); + } + } else { + stream_info.ts = current_time_secs(); + } + + if let Some(token) = session_token { + stream_info.session_token = Some(token.to_string()); + } + if was_preserved { + stream_info.preserved = false; + } + connection_data.stream_normal_priorities.insert(stream_info.uid, priority); + let result = stream_info.clone(); + stream_info.previous_session_id = None; + (result, was_preserved) + }); + let (stream_info, divergence_snapshot) = if let Some((stream_info, was_preserved)) = existing_stream_info { + let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind); + if was_preserved { + connection_data.increment_kind(effective_connection_kind); + } + connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind); + connection_data.stream_normal_priorities.insert(stream_info.uid, priority); + if let Some(token) = session_token { + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + Self::mark_session_committed(session, effective_connection_kind); + } + } + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + (stream_info, divergence_snapshot) + } else { + let effective_connection_kind = reserved_session_kind.unwrap_or(connection_kind); + let country_code = self.lookup_country(&fingerprint.client_ip); + + let mut stream_info = StreamInfo::new(shared::model::StreamInfoParams { + uid, + meter_uid, + username, + addr: &fingerprint.addr, + client_ip: &fingerprint.client_ip, + provider, + stream_channel: stream_channel.clone(), + user_agent: user_agent_string, + country_code, + session_token, + }); + + if let Some(token) = session_token { + if let Some(session) = connection_data.sessions.iter().find(|s| s.token == token) { + stream_info.started_at = session.started_at; + } + } + + if reserved_session_kind.is_none() { + connection_data.increment_kind(effective_connection_kind); + } + connection_data.streams.push(stream_info.clone()); + connection_data.stream_kinds.insert(stream_info.uid, effective_connection_kind); + connection_data.stream_normal_priorities.insert(stream_info.uid, priority); + if let Some(token) = session_token { + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + Self::mark_session_committed(session, effective_connection_kind); + } + } + Self::log_connection_added(username, &fingerprint.addr, connection_data, tracked_socket_count); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + (stream_info, divergence_snapshot) + }; + let connection_count_changed = connection_data.connections != previous_connection_count; + (stream_info, divergence_snapshot, connection_count_changed) + }; + + self.log_divergence_snapshot(divergence_snapshot).await; + + if connection_count_changed { + self.log_active_user().await; + } + + Some(stream_info) + } + + fn is_log_user_enabled(&self) -> bool { self.log_active_user.load(Ordering::Relaxed) } + + fn build_preserved_stream_expiry( + &self, + username: &str, + stream: &StreamInfo, + sessions: &[UserSession], + ) -> Option { + let session_token = stream.session_token.as_deref()?; + // Catchup segment gaps can briefly lose the UserSession row; still preserve the panel + // row using the stream timestamp so Streams does not blink between archive chunks. + let session_ts = if let Some(session) = sessions.iter().find(|session| session.token == session_token) { + session.ts + } else if stream.channel.item_type == PlaylistItemType::Catchup || is_catchup_session_token(session_token) { + stream.ts + } else { + return None; + }; + + let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); + let expires_at = session_ts.saturating_add(ttl_secs); + Some(AdaptiveExpiryEntry { + expires_at, + username: username.to_string(), + session_token: session_token.to_string(), + uid: stream.uid, + }) + } + + async fn enqueue_adaptive_expiry(&self, entry: AdaptiveExpiryEntry) { + let key = AdaptiveExpiryKey { + username: entry.username.clone(), + session_token: entry.session_token.clone(), + uid: entry.uid, + }; + + let mut expiry_index = self.adaptive_expiry_index.lock().await; + expiry_index.insert(key, entry.expires_at); + drop(expiry_index); + + let mut queue = self.adaptive_expiry_queue.lock().await; + let wake_worker = queue.peek().is_none_or(|current| entry.expires_at < current.0.expires_at); + queue.push(Reverse(entry)); + if wake_worker { + self.adaptive_expiry_notify.notify_one(); + } + } + + fn new_user_session(params: &UserSessionParams<'_>) -> UserSession { + let now = current_time_secs(); + UserSession { + token: params.session_token.to_string(), + transition_version: 1, + 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], + ts: now, + started_at: now, + permission: params.connection_permission, + connection_kind: params.connection_kind, + lifecycle: PlaybackLifecycle::Prepared, + } + } + + fn promote_session_for_stream(connection_data: &mut UserConnectionData, stream: &StreamInfo) { + if let Some(token) = stream.session_token.as_deref() { + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + Self::mark_session_committed(session, ConnectionKind::Normal); + } + } + } + + fn collect_promotions_after_capacity_release(connection_data: &mut UserConnectionData) -> Vec { + let mut promotions = Vec::new(); + while let Some(action) = connection_data.try_promote_soft_stream() { + let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); + if let Some(stream) = promoted_stream.as_ref() { + Self::promote_session_for_stream(connection_data, stream); + } + promotions.push(action); + } + while connection_data.try_promote_soft_session_reservation() {} + promotions + } + + fn promote_counted_soft_session_to_normal_if_available( + connection_data: &mut UserConnectionData, + session_token: &str, + ) -> Vec { + if connection_data.max_connections > 0 && connection_data.counts.normal >= connection_data.max_connections { + return Vec::new(); + } + + let Some(session_index) = connection_data.sessions.iter().position(|session| { + session.token == session_token + && session.lifecycle.is_counted() + && session.connection_kind == Some(ConnectionKind::Soft) + }) else { + return Vec::new(); + }; + + if connection_data.counts.soft == 0 { + return Vec::new(); + } + + connection_data.counts.normal = connection_data.counts.normal.saturating_add(1); + connection_data.counts.soft = connection_data.counts.soft.saturating_sub(1); + connection_data.sessions[session_index].connection_kind = Some(ConnectionKind::Normal); + Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); + + let mut promotions = Vec::new(); + for stream in + connection_data.streams.iter().filter(|stream| stream.session_token.as_deref() == Some(session_token)) + { + if connection_data.stream_kinds.get(&stream.uid) != Some(&ConnectionKind::Soft) { + continue; + } + let new_priority = connection_data.stream_normal_priorities.get(&stream.uid).copied().unwrap_or_default(); + connection_data.stream_kinds.insert(stream.uid, ConnectionKind::Normal); + promotions.push(PromotionAction { addr: stream.addr, uid: stream.uid, new_priority }); + } + promotions + } + + fn bump_session_transition_version(session: &mut UserSession) -> u64 { + session.transition_version = session.transition_version.saturating_add(1); + session.transition_version + } + + fn mark_session_committed(session: &mut UserSession, kind: ConnectionKind) { + session.connection_kind = Some(kind); + session.lifecycle = PlaybackLifecycle::Active; + Self::bump_session_transition_version(session); + } + + fn update_session_admission( + session: &mut UserSession, + permission: UserConnectionPermission, + kind: Option, + ) { + session.permission = permission; + if let Some(kind) = kind { + session.connection_kind = Some(kind); + } + } + + fn clear_session_pending_with_permission( + session: &mut UserSession, + permission: UserConnectionPermission, + wake_source: PendingProviderWakeSource, + ) { + if let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle { + data.wake_source = Some(wake_source); + } + Self::bump_session_transition_version(session); + session.permission = permission; + } + + fn session_has_stream(connection_data: &UserConnectionData, session_token: &str) -> bool { + connection_data.streams.iter().any(|stream| stream.session_token.as_deref() == Some(session_token)) + } + + fn clear_session_counted_without_stream(connection_data: &mut UserConnectionData, session_token: &str) { + if Self::session_has_stream(connection_data, session_token) { + return; + } + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { + match session.lifecycle { + PlaybackLifecycle::Active => { + session.lifecycle = PlaybackLifecycle::Preserved; + } + // GraceActive without a stream: grace failed, expire the session. + // This can happen when the grace window times out while the client + // is still connecting but hasn't opened a stream yet. + PlaybackLifecycle::GraceActive => { + session.lifecycle = PlaybackLifecycle::Expired; + } + _ => {} + } + } + } + + fn clear_session_counted(connection_data: &mut UserConnectionData, session_token: &str) { + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { + match session.lifecycle { + PlaybackLifecycle::Active => { + session.lifecycle = PlaybackLifecycle::Preserved; + } + PlaybackLifecycle::GraceActive => { + // GraceActive without stream: the grace failed. The stream was already + // removed (this function is called after stream removal), so expire the session. + session.lifecycle = PlaybackLifecycle::Expired; + } + _ => {} + } + } + } + + fn release_expired_session_reservations(connection_data: &mut UserConnectionData, now: u64) { + let expired_counted = connection_data + .sessions + .iter() + .filter(|session| session.lifecycle.is_counted()) + .filter(|session| now.saturating_sub(session.ts) >= USER_CON_TTL) + .filter(|session| !Self::session_has_stream(connection_data, session.token.as_str())) + .map(|session| (session.token.clone(), session.connection_kind.unwrap_or(ConnectionKind::Normal))) + .collect::>(); + + for (_, kind) in &expired_counted { + connection_data.decrement_kind(*kind); + } + for (token, _) in expired_counted { + Self::clear_session_counted_without_stream(connection_data, &token); + } + while connection_data.try_promote_soft_session_reservation() {} + } + + pub async fn connection_admission_for_session_activation( + &self, + username: &str, + max_connections: u32, + soft_connections: u16, + session_token: &str, + ) -> ConnectionAdmission { + if max_connections == 0 && soft_connections == 0 { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: Some(ConnectionKind::Normal), + }; + } + + let mut connections = self.connections.write().await; + let Some(connection_data) = connections.by_key.get_mut(username) else { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: Some(ConnectionKind::Normal), + }; + }; + connection_data.max_connections = max_connections; + connection_data.soft_connections = soft_connections; + + let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == session_token) + else { + return self.check_connection_admission(username, connection_data); + }; + + // Existing counted session or any stream row for this token (including soft-preserved + // HLS/Catchup between segments): entitled to its slot. #807 switched this to + // active-only stream checks, so every LiveHls segment gap returned Exhausted + // and kick-evicted/terminated the same session (retry storm since v3.3.79). + if connection_data.sessions[session_index].lifecycle.is_counted() + || Self::session_has_stream(connection_data, session_token) + { + return ConnectionAdmission { + permission: UserConnectionPermission::Allowed, + kind: connection_data.sessions[session_index].connection_kind.or(Some(ConnectionKind::Normal)), + }; + } + + // Uncounted session with no stream row: run normal admission. + // Same-token soft-preserve is handled above via `session_has_stream` (3.3.78 semantics). + // Do not return Exhausted for own preserved rows — that forced self-eviction on HLS gaps. + let admission = self.check_connection_admission_with_counts( + username, + connection_data, + connection_data.effective_counts_for_admission(Some(session_token)), + ); + if admission.permission == UserConnectionPermission::Allowed { + let session = &mut connection_data.sessions[session_index]; + Self::update_session_admission(session, admission.permission, admission.kind); + } + admission + } + + pub async fn ensure_user_session_placeholder(&self, request: CreateUserSessionParams<'_>) -> u64 { + let CreateUserSessionParams { + user, + session_token, + virtual_id, + provider, + stream_url, + addr, + connection_permission, + connection_kind, + socket_bound, + } = request; + self.gc(); + + let username = user.username.clone(); + let mut user_connections = self.connections.write().await; + let connection_data = user_connections + .by_key + .entry(username.clone()) + .or_insert_with(|| UserConnectionData::new(0, user.max_connections, user.soft_connections)); + + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == session_token) { + session.ts = current_time_secs(); + session.socket_bound = socket_bound; + remember_session_addr(session, *addr); + if session.connection_kind.is_none() { + session.connection_kind = connection_kind; + } + if session.permission == UserConnectionPermission::Exhausted { + Self::update_session_admission(session, connection_permission, None); + } + let version = Self::bump_session_transition_version(session); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + return version; + } + + let session = Self::new_user_session(&UserSessionParams { + session_token, + virtual_id, + provider, + stream_url, + addr, + connection_permission, + connection_kind, + socket_bound, + }); + let version = session.transition_version; + connection_data.add_session(session); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + version + } + + pub async fn release_unbound_session_reservation( + &self, + username: &str, + session_token: &str, + expected_transition_version: Option, + remove_session_if_unbound: bool, + ) { + let (connection_changed, user_removed, promotions, divergence_snapshot) = { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + + if Self::session_has_stream(connection_data, session_token) { + return; + } + + let Some(session_index) = + connection_data.sessions.iter().position(|session| session.token == session_token) + else { + return; + }; + + if expected_transition_version + .is_some_and(|expected| connection_data.sessions[session_index].transition_version != expected) + { + return; + } + + let mut connection_changed = false; + if connection_data.sessions[session_index].lifecycle.is_counted() { + let kind = connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal); + connection_data.decrement_kind(kind); + connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired; + connection_changed = true; + } + connection_data.sessions[session_index].transition_version = + connection_data.sessions[session_index].transition_version.saturating_add(1); + + if remove_session_if_unbound { + connection_data.sessions.swap_remove(session_index); + } + + if connection_data.connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + + let mut promotions = Vec::new(); + while let Some(action) = connection_data.try_promote_soft_stream() { + let promoted_stream = connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); + if let Some(stream) = promoted_stream.as_ref() { + Self::promote_session_for_stream(connection_data, stream); + } + promotions.push(action); + } + while connection_data.try_promote_soft_session_reservation() {} + + let user_removed = connection_data.connections == 0 + && connection_data.streams.is_empty() + && connection_data.sessions.is_empty(); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + + (connection_changed, user_removed, promotions, divergence_snapshot) + }; + + self.log_divergence_snapshot(divergence_snapshot).await; + + if user_removed { + let mut user_connections = self.connections.write().await; + user_connections.by_key.remove(username); + } + if connection_changed || user_removed { + self.log_active_user().await; + } + for action in promotions { + self.emit_promotion_update(username, action).await; + } + } + + pub async fn release_session_streams_and_counted_reservation(&self, username: &str, session_token: &str) -> bool { + let (connection_changed, user_removed, promotions, divergence_snapshot) = { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return false; + }; + + let counted_kind = connection_data + .sessions + .iter() + .find(|session| session.token == session_token && session.lifecycle.is_counted()) + .and_then(|session| session.connection_kind); + let (_removed_streams, mut connection_changed) = + connection_data.remove_streams_for_session_and_release_counted(session_token, counted_kind); + Self::clear_session_counted_without_stream(connection_data, session_token); + + if connection_data.connections < connection_data.max_connections { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + + let promotions = Self::collect_promotions_after_capacity_release(connection_data); + let user_removed = connection_data.connections == 0 + && connection_data.streams.is_empty() + && connection_data.sessions.is_empty(); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + connection_changed |= !promotions.is_empty(); + + (connection_changed, user_removed, promotions, divergence_snapshot) + }; + + self.log_divergence_snapshot(divergence_snapshot).await; + + if user_removed { + let mut user_connections = self.connections.write().await; + user_connections.by_key.remove(username); + } + if connection_changed || user_removed { + self.log_active_user().await; + } + for action in promotions { + self.emit_promotion_update(username, action).await; + } + connection_changed || user_removed + } + + pub async fn create_user_session(&self, request: CreateUserSessionParams<'_>) -> String { + let CreateUserSessionParams { + user, + session_token, + virtual_id, + provider, + stream_url, + addr, + connection_permission, + connection_kind, + socket_bound, + } = request; + self.gc(); + + let username = user.username.clone(); + let mut user_connections = self.connections.write().await; + let connection_data = user_connections.by_key.entry(username.clone()).or_insert_with(|| { + debug_if_enabled!("Creating first session for user {username} {}", sanitize_sensitive_info(stream_url)); + let mut data = UserConnectionData::new(0, user.max_connections, user.soft_connections); + let session = Self::new_user_session(&UserSessionParams { + session_token, + virtual_id, + provider, + stream_url, + addr, + connection_permission, + connection_kind, + socket_bound, + }); + data.add_session(session); + data + }); + + // If a session exists, update it + for session in &mut connection_data.sessions { + if session.token == session_token { + session.ts = current_time_secs(); + 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 + // when a new request arrives for the same session token - the request is either + // a reactivation (Activate) or a follow-up on a still-valid logical playback. + match session.lifecycle { + PlaybackLifecycle::Expired => { + session.lifecycle = PlaybackLifecycle::Prepared; + } + // PendingProvider: pending wait continues until explicitly resolved. + // Preserved: stays preserved until explicit reactivation via activation path. + // Prepared: placeholder session, no counted lease. + // Active: session is already in a valid counted state. + // All these keep their current state - session.refresh() alone does not advance it. + #[allow(clippy::match_same_arms)] + PlaybackLifecycle::PendingProvider { .. } + | PlaybackLifecycle::Preserved + | PlaybackLifecycle::Prepared + | PlaybackLifecycle::Active => {} + PlaybackLifecycle::GraceActive => { + // GraceActive refresh keeps the provisional state. Grace window is still + // running — refresh does not advance it. The grace task will resolve it. + } + } + Self::update_session_admission(session, connection_permission, connection_kind); + debug_if_enabled!( + "Using session for user {} with url: {}", + user.username, + sanitize_sensitive_info(stream_url) + ); + return session.token.clone(); + } + } + + // If no session exists, create one + debug_if_enabled!( + "Creating session for user {} with url: {}", + user.username, + sanitize_sensitive_info(stream_url) + ); + let session = Self::new_user_session(&UserSessionParams { + session_token, + virtual_id, + provider, + stream_url, + addr, + connection_permission, + connection_kind, + socket_bound, + }); + let token = session.token.clone(); + connection_data.add_session(session); + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, &username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + token + } + + pub async fn update_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) { + let now = current_time_secs(); + let mut user_connections = self.connections.write().await; + if let Some(connection_data) = user_connections.by_key.get_mut(username) { + let update_result = if let Some(session) = connection_data.sessions.iter_mut().find(|s| s.token == token) { + let previous_addr = session.addr; + remember_session_addr(session, *addr); + session.ts = now; + Self::bump_session_transition_version(session); + for stream in &mut connection_data.streams { + if stream.addr == previous_addr { + stream.addr = *addr; + stream.ts = now; + } + } + let prune_previous_registration = previous_addr != *addr + && !connection_data.has_session_addr(&previous_addr) + && !connection_data.streams.iter().any(|stream| stream.addr == previous_addr); + Some((previous_addr, prune_previous_registration)) + } else { + None + }; + + if let Some((previous_addr, prune_previous_registration)) = update_result { + if let Some(registration) = user_connections.key_by_addr.get_mut(addr) { + registration.ts = now; + registration.username = username.to_string(); + } else { + user_connections + .key_by_addr + .insert(*addr, SocketRegistration { username: username.to_string(), ts: now }); + } + if prune_previous_registration { + let can_remove_previous = user_connections + .key_by_addr + .get(&previous_addr) + .is_some_and(|registration| registration.username == username); + if can_remove_previous { + user_connections.key_by_addr.remove(&previous_addr); + } + } + debug_if_enabled!( + "Updated session {token} for {username} address {} -> {}", + sanitize_sensitive_info(&previous_addr.to_string()), + sanitize_sensitive_info(&addr.to_string()) + ); + } + } + } + + pub async fn clear_unbound_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) { + let now = current_time_secs(); + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + let addr_has_active_stream_for_session = connection_data + .streams + .iter() + .any(|stream| stream.session_token.as_deref() == Some(token) && stream.addr == *addr && !stream.preserved); + if addr_has_active_stream_for_session { + return; + } + + let cleared = if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) + { + let changed = clear_session_addr(session, addr); + if changed { + Self::bump_session_transition_version(session); + } + changed + } else { + false + }; + if !cleared { + let can_remove_registration = !connection_data.has_session_addr(addr) + && !connection_data.streams.iter().any(|stream| stream.addr == *addr); + if can_remove_registration { + let can_remove = user_connections + .key_by_addr + .get(addr) + .is_some_and(|registration| registration.username.is_empty() || registration.username == username); + if can_remove { + user_connections.key_by_addr.remove(addr); + } + } + return; + } + + let can_remove_registration = !connection_data.has_session_addr(addr) + && !connection_data.streams.iter().any(|stream| stream.addr == *addr); + if can_remove_registration { + let can_remove = user_connections + .key_by_addr + .get(addr) + .is_some_and(|registration| registration.username.is_empty() || registration.username == username); + if can_remove { + user_connections.key_by_addr.remove(addr); + } + } else if let Some(registration) = user_connections.key_by_addr.get_mut(addr) { + registration.ts = now; + } + } + + pub async fn mark_pending_provider( + &self, + username: &str, + token: &str, + reason_code: PendingProviderReason, + deadline: u64, + ) -> Option { + let mut user_connections = self.connections.write().await; + let connection_data = user_connections.by_key.get_mut(username)?; + let now = current_time_secs(); + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + let version = match session.lifecycle { + PlaybackLifecycle::PendingProvider { ref data } => data.version.saturating_add(1), + _ => 1, + }; + // Capture counted status BEFORE lifecycle transition to PendingProvider. + // is_counted() returns false for PendingProvider, so we must check first. + let kind = if session.lifecycle.is_counted() { + Some(session.connection_kind.unwrap_or(ConnectionKind::Normal)) + } else { + None + }; + session.ts = now; + Self::bump_session_transition_version(session); + Self::update_session_admission(session, UserConnectionPermission::GracePeriod, None); + session.lifecycle = PlaybackLifecycle::PendingProvider { + data: PendingProviderState { reason_code, created_at: now, deadline, version, wake_source: None }, + }; + if let Some(kind) = kind { + connection_data.decrement_kind(kind); + } + return Some(version); + } + None + } + + pub async fn activate_pending_provider( + &self, + username: &str, + token: &str, + expected_version: u64, + wake_source: PendingProviderWakeSource, + ) { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + let PlaybackLifecycle::PendingProvider { data } = &mut session.lifecycle else { + return; + }; + if data.version != expected_version { + return; + } + data.wake_source = Some(wake_source); + Self::bump_session_transition_version(session); + session.permission = UserConnectionPermission::Allowed; + session.lifecycle = PlaybackLifecycle::Active; + } + } + + /// Returns the current `transition_version` if the session is in `GraceActive` lifecycle. + /// Used by the grace task to confirm the session is still in `GraceActive` before committing. + pub async fn grace_active_version(&self, username: &str, token: &str) -> Option { + let connections = self.connections.read().await; + let connection_data = connections.by_key.get(username)?; + let session = connection_data.sessions.iter().find(|s| s.token == token)?; + if session.lifecycle == PlaybackLifecycle::GraceActive { + Some(session.transition_version) + } else { + None + } + } + + /// Marks a session as `GraceActive` — the session was granted immediate grace + /// (`GraceMode::Instant`) and is provisionally active. The session counts against + /// admission limits in this state. + /// + /// This corresponds to `Prepared -> GraceActive` in the playback state machine. + /// The session remains in `GraceActive` until either: + /// - `activate_grace_active` confirms it (grace window succeeded -> `GraceActive -> Active`) + /// - `expire_grace_active` expires it (grace window failed -> `GraceActive -> Expired`) + pub async fn mark_grace_active(&self, username: &str, token: &str) { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { + return; + }; + if connection_data.sessions[session_index].lifecycle == PlaybackLifecycle::GraceActive { + return; // already grace active + } + // Collect fields while only borrowing sessions. + let kind = connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal); + let needs_count = !connection_data.sessions[session_index].lifecycle.is_counted(); + let now = current_time_secs(); + // Now mutate. Use index access to avoid nested &mut borrows. + connection_data.sessions[session_index].ts = now; + Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); + if needs_count { + connection_data.increment_kind(kind); + } + connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::GraceActive; + } + + /// Activates a `GraceActive` session when the grace window resolves successfully. + /// + /// This corresponds to `GraceActive -> Active` in the playback state machine. + /// The session remains counted and the kind counts are already correct from + /// the `GraceActive` provisional state. + pub async fn activate_grace_active(&self, username: &str, token: &str, expected_version: u64) { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + if let Some(session) = connection_data.sessions.iter_mut().find(|session| session.token == token) { + if session.transition_version != expected_version { + return; + } + if session.lifecycle != PlaybackLifecycle::GraceActive { + return; + } + Self::bump_session_transition_version(session); + session.lifecycle = PlaybackLifecycle::Active; + session.permission = UserConnectionPermission::Allowed; + } + } + + /// Expires a `GraceActive` session when the grace window fails. + /// + /// This corresponds to `GraceActive -> Expired` in the playback state machine. + /// Releases the provisional counted lease. + pub async fn expire_grace_active(&self, username: &str, token: &str, expected_version: u64) { + let (connection_changed, removed_count) = { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { + return; + }; + + if connection_data.sessions[session_index].transition_version != expected_version { + return; + } + if connection_data.sessions[session_index].lifecycle != PlaybackLifecycle::GraceActive { + return; + } + + // Release the provisional counted lease using index-based access + // to avoid nested mutable borrows with connection_data methods. + let mut connection_changed = false; + let mut counted_kind: Option = None; + if connection_data.sessions[session_index].lifecycle.is_counted() { + counted_kind = connection_data.sessions[session_index].connection_kind; + connection_changed = true; + } + if let Some(kind) = counted_kind { + connection_data.decrement_kind(kind); + } + + // Expire the session. Lifecycle change alone handles counted state (Expired is not counted). + connection_data.sessions[session_index].lifecycle = PlaybackLifecycle::Expired; + connection_data.sessions[session_index].permission = UserConnectionPermission::Exhausted; + Self::bump_session_transition_version(&mut connection_data.sessions[session_index]); + + // Collect addresses for stream cleanup. + let mut addrs = Vec::new(); + let session_addr = connection_data.sessions[session_index].addr; + if !session_addr.ip().is_unspecified() { + addrs.push(session_addr); + } + for addr in &connection_data.sessions[session_index].active_addrs { + if *addr != session_addr && !addrs.contains(addr) { + addrs.push(*addr); + } + } + + // Remove all streams for these addresses (never preserve on expire). + let mut removed_count = 0; + for addr in &addrs { + if let Some(stream_idx) = + connection_data.streams.iter().position(|stream| stream.addr == *addr && !stream.preserved) + { + if let Some(kind) = connection_data.stream_kinds.remove(&connection_data.streams[stream_idx].uid) { + connection_data.decrement_kind(kind); + } + connection_data.stream_normal_priorities.remove(&connection_data.streams[stream_idx].uid); + connection_data.streams.swap_remove(stream_idx); + removed_count += 1; + } + } + + // Reset grace if no connections remain. + if connection_data.connections == 0 && connection_data.streams.is_empty() { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + + (connection_changed, removed_count) + }; + + if connection_changed { + self.log_active_user().await; + } + debug!("GraceActive expired for session {token} in {username}, released {removed_count} streams"); + } + + /// Terminates the session and all associated streams for a playback. + /// + /// This is the explicit `Terminate` path from the playback state machine: + /// - Removes all streams associated with this session token (never preserves) + /// - Releases the counted lease if held + /// - Sets lifecycle to `Expired` + /// - Clears pending-provider state + /// + /// Unlike `release_unbound_session_reservation`, this terminates regardless of + /// whether streams are currently active, and always removes associated streams. + pub async fn terminate_session(&self, username: &str, session_token: &str) { + let (connection_changed, removed_count, promotions) = { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + + let Some(session_index) = + connection_data.sessions.iter().position(|session| session.token == session_token) + else { + return; + }; + + let counted_kind = connection_data.sessions[session_index] + .lifecycle + .is_counted() + .then(|| connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)); + let (removed_count, connection_changed) = + connection_data.remove_streams_for_session_and_release_counted(session_token, counted_kind); + + // Expire and remove the session immediately. Unlike `release_unbound_session_reservation` + // which keeps the expired session for TTL-based GC cleanup, terminate_session explicitly + // removes the session from the list so `get_and_update_user_session` returns None. + connection_data.sessions.swap_remove(session_index); + + // Reset grace if no connections remain. + if connection_data.connections == 0 && connection_data.streams.is_empty() { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + + let promotions = Self::collect_promotions_after_capacity_release(connection_data); + + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + + (connection_changed, removed_count, promotions) + }; + + if connection_changed { + self.log_active_user().await; + } + for action in promotions { + self.emit_promotion_update(username, action).await; + } + debug!("Terminated session {session_token} for user {username}, released {removed_count} streams"); + } + + /// Terminates all sessions associated with a given socket address for a user. + /// + /// This is used when a connection is explicitly kicked — the session should be + /// expired and removed immediately rather than waiting for TTL-based GC cleanup. + /// + /// Removes all sessions whose `addr` or `active_addrs` contains `kick_addr`. + pub async fn terminate_sessions_for_addr(&self, username: &str, kick_addr: &SocketAddr) { + let (connection_changed, removed_count, promotions) = { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + + // Collect tokens of sessions associated with the kicked addr. + let tokens_to_remove: Vec = connection_data + .sessions + .iter() + .filter(|session| session.addr == *kick_addr || session.active_addrs.contains(kick_addr)) + .map(|session| session.token.clone()) + .collect(); + + if tokens_to_remove.is_empty() { + return; + } + + let mut removed_count = 0; + let mut connection_changed = false; + + for token in &tokens_to_remove { + let Some(session_index) = connection_data.sessions.iter().position(|s| s.token == *token) else { + continue; + }; + + let counted_kind = connection_data.sessions[session_index] + .lifecycle + .is_counted() + .then(|| connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)); + + let (_, session_connection_changed) = + connection_data.remove_streams_for_session_and_release_counted(token, counted_kind); + connection_changed |= session_connection_changed; + + // Expire and remove the session. + connection_data.sessions.swap_remove(session_index); + removed_count += 1; + } + + // Reset grace if no connections remain. + if connection_data.connections == 0 && connection_data.streams.is_empty() { + connection_data.granted_grace = false; + connection_data.grace_ts = 0; + } + + let promotions = Self::collect_promotions_after_capacity_release(connection_data); + + let divergence_snapshot = Self::collect_divergence_snapshot(connection_data, username); + drop(user_connections); + self.log_divergence_snapshot(divergence_snapshot).await; + + (connection_changed, removed_count, promotions) + }; + + if connection_changed { + self.log_active_user().await; + } + for action in promotions { + self.emit_promotion_update(username, action).await; + } + debug!("Terminated {removed_count} sessions for user {username} at addr {kick_addr}"); + } + + pub async fn expire_pending_provider( + &self, + username: &str, + token: &str, + expected_version: u64, + wake_source: PendingProviderWakeSource, + ) { + let mut user_connections = self.connections.write().await; + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + let Some(session_index) = connection_data.sessions.iter().position(|session| session.token == token) else { + return; + }; + let pending_version = match &connection_data.sessions[session_index].lifecycle { + PlaybackLifecycle::PendingProvider { data } => data.version, + _ => return, + }; + if pending_version != expected_version { + return; + } + // Capture counted status BEFORE lifecycle changes. + // PendingProvider is not counted (is_counted() = false), so checking here + // captures whether there is a previously-counted lease to release. + let kind_to_release = if connection_data.sessions[session_index].lifecycle.is_counted() { + Some(connection_data.sessions[session_index].connection_kind.unwrap_or(ConnectionKind::Normal)) + } else { + None + }; + let session = &mut connection_data.sessions[session_index]; + Self::clear_session_pending_with_permission(session, UserConnectionPermission::Exhausted, wake_source); + session.lifecycle = PlaybackLifecycle::Expired; + if let Some(kind) = kind_to_release { + connection_data.decrement_kind(kind); + } + } + + pub async fn adaptive_session_stream_cleanup_addrs( + &self, + username: &str, + session_token: &str, + current_addr: &SocketAddr, + ) -> Vec { + let connections = self.connections.read().await; + let Some(connection_data) = connections.by_key.get(username) else { + return Vec::new(); + }; + + let mut addrs = Vec::new(); + for stream in + connection_data.streams.iter().filter(|stream| stream.session_token.as_deref() == Some(session_token)) + { + if stream.addr != *current_addr && !addrs.contains(&stream.addr) { + addrs.push(stream.addr); + } + } + let current_addr_string = current_addr.to_string(); + let current_ip = strip_port(¤t_addr_string).to_string(); + if let Some(session) = connection_data.sessions.iter().find(|session| session.token == session_token) { + for addr in &session.active_addrs { + let addr_string = addr.to_string(); + let addr_ip = strip_port(&addr_string); + if *addr != *current_addr && addr_ip == current_ip && !addrs.contains(addr) { + addrs.push(*addr); + } + } + } + addrs + } + + pub fn active_socket_ttl_secs(&self) -> u64 { + let configured_ttl = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); + if configured_ttl == 0 { + DEFAULT_ACTIVE_SOCKET_TTL_SECS + } else { + configured_ttl + } + } + + pub async fn socket_expiry_deadline(&self, addr: &SocketAddr) -> Option { + let ttl_secs = self.active_socket_ttl_secs(); + let connections = self.connections.read().await; + let registration = connections.key_by_addr.get(addr)?; + if registration.username.is_empty() { + return None; + } + + Some(registration.ts.saturating_add(ttl_secs)) + } + + pub async fn touch_socket_activity(&self, addr: &SocketAddr) { + let now = current_time_secs(); + let mut user_connections = self.connections.write().await; + let Some(username) = user_connections.key_by_addr.get_mut(addr).and_then(|registration| { + if registration.username.is_empty() { + None + } else { + registration.ts = now; + Some(registration.username.clone()) + } + }) else { + return; + }; + + if let Some(connection_data) = user_connections.by_key.get_mut(&username) { + connection_data.ts = now; + } + } + + pub async fn touch_http_activity(&self, username: &str, token: &str, addr: &SocketAddr) { + let now = current_time_secs(); + let mut user_connections = self.connections.write().await; + + let registration = user_connections.key_by_addr.entry(*addr).or_insert_with(SocketRegistration::anonymous); + registration.username = username.to_string(); + registration.ts = now; + + let Some(connection_data) = user_connections.by_key.get_mut(username) else { + return; + }; + + connection_data.ts = now; + + for session in &mut connection_data.sessions { + if session.token == token { + // Lightweight HTTP activity (for example HLS manifest reloads) refreshes + // continuity metadata only. It must not become an active stream socket: + // otherwise a manifest or probe request can steal the visible stream addr, + // and the real segment socket later migrates to that stale addr instead of + // being released/preserved. + session.ts = now; + break; + } + } + } + + pub async fn get_and_update_user_session(&self, username: &str, token: &str) -> Option { + self.update_user_session(username, token).await + } + + /// Session for target-scoped `virtual_id` and request token (used to recover leaked relative DVR segment paths). + pub async fn find_latest_session_for_target_stream( + &self, + username: &str, + target_id: u16, + input_name: &str, + virtual_id: u32, + session_token: &str, + ) -> Option { + let user_connections = self.connections.read().await; + let connection_data = user_connections.by_key.get(username)?; + connection_data + .streams + .iter() + .any(|stream| { + stream.channel.target_id == target_id + && stream.channel.input_name.as_ref() == input_name + && stream.channel.virtual_id == virtual_id + && stream.session_token.as_deref() == Some(session_token) + }) + .then_some(())?; + + connection_data + .sessions + .iter() + .find(|session| session.token == session_token && session.virtual_id == virtual_id) + .cloned() + } + + pub async fn update_session_provider_headers( + &self, + username: &str, + token: &str, + provider_session_headers: &HashMap, + ) -> 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 { + let user_connections = self.connections.read().await; + let connection_data = user_connections.by_key.get(username)?; + let session = connection_data.sessions.iter().find(|session| session.token == token)?; + match &session.lifecycle { + PlaybackLifecycle::PendingProvider { data } => Some(data.version), + _ => None, + } + } + + async fn update_user_session(&self, username: &str, token: &str) -> Option { + let mut user_connections = self.connections.write().await; + + let connection_data = user_connections.by_key.get_mut(username)?; + let now = current_time_secs(); + + connection_data.ts = now; + + let session_index = connection_data.sessions.iter().position(|s| s.token == token)?; + + connection_data.sessions[session_index].ts = now; + + if connection_data.max_connections > 0 + && connection_data.sessions[session_index].permission == UserConnectionPermission::GracePeriod + && !matches!(connection_data.sessions[session_index].lifecycle, PlaybackLifecycle::PendingProvider { .. }) + { + let admission = self.check_connection_admission(username, connection_data); + connection_data.sessions[session_index].permission = admission.permission; + if admission.kind.is_some() { + connection_data.sessions[session_index].connection_kind = admission.kind; + } + } + + Some(connection_data.sessions[session_index].clone()) + } + + pub async fn active_streams(&self) -> Vec { + self.gc(); + let user_connections = self.connections.read().await; + let mut streams = Vec::new(); + for connection_data in user_connections.by_key.values() { + for stream in &connection_data.streams { + // Keep active_streams free of preserved rows — shared-HLS join detection and + // connection accounting must not see stale archive segment leases (v3.3.81). + if !stream.preserved { + streams.push(stream.clone()); + } + } + } + streams + } + + /// Streams for the `WebUI` / `StatusCheck` snapshot. + /// + /// Includes preserved Catchup/HLS/DASH session rows so the panel keeps showing archive + /// playback between short segment sockets. Do not use this for shared-HLS accounting. + pub async fn panel_streams(&self) -> Vec { + self.gc(); + let user_connections = self.connections.read().await; + let mut streams = Vec::new(); + for connection_data in user_connections.by_key.values() { + for stream in &connection_data.streams { + if !stream.preserved || Self::should_preserve_session_stream(stream) { + streams.push(stream.clone()); + } + } + } + streams + } + + fn log_connection_added( + username: &str, + addr: &SocketAddr, + connection_data: &UserConnectionData, + tracked_socket_count: usize, + ) { + if log::log_enabled!(log::Level::Debug) { + let active_for_user = connection_data.connections; + if connection_data.max_connections > 0 && active_for_user > connection_data.max_connections { + let recent_sockets = connection_data + .streams + .iter() + .rev() + .take(3) + .map(|stream| stream.addr.to_string()) + .collect::>() + .join(", "); + let recent_sockets = if recent_sockets.is_empty() { String::from("n/a") } else { recent_sockets }; + let unique_clients = + connection_data.streams.iter().map(|stream| &stream.client_ip).collect::>().len(); + debug!( + "User {username} exceeded configured max connections ({}/{}). Unique clients: {}, recent sockets [{}]", + active_for_user, + connection_data.max_connections, + unique_clients, + recent_sockets + ); + } else { + debug_if_enabled!( + "Added new connection for {username} at {} (active user connections={active_for_user}, tracked sockets={tracked_socket_count})", + sanitize_sensitive_info(&addr.to_string()) + ); + } + } + } + + pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool { + let connections = self.connections.read().await; + let now = current_time_secs(); + matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now) + } + + pub async fn recently_evicted_session_protected_addr(&self, session_token: &str) -> Option { + let connections = self.connections.read().await; + let now = current_time_secs(); + let protection = connections.recently_evicted_sessions.get(session_token)?; + if protection.expires_at > now { + return Some(protection.protected_addr); + } + + let username = connections.by_key.iter().find_map(|(username, connection_data)| { + connection_data.sessions.iter().any(|session| session.token == session_token).then_some(username.as_str()) + })?; + connections + .key_by_addr + .get(&protection.protected_addr) + .filter(|registration| registration.username == username) + .map(|_| protection.protected_addr) + } + + pub async fn recent_socket_reentry_protected_addr( + &self, + username: &str, + client_ip: &str, + virtual_id: VirtualId, + ) -> Option { + let connections = self.connections.read().await; + let now = current_time_secs(); + let key = create_socket_reentry_guard_key(username, client_ip, virtual_id); + let protection = connections.recent_socket_reentry_guards.get(&key)?; + if protection.expires_at > now { + return Some(protection.protected_addr); + } + + connections + .key_by_addr + .get(&protection.protected_addr) + .filter(|registration| registration.username == username) + .map(|_| protection.protected_addr) + } + + pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) { + let block_for_secs = blocked_secs.clamp(0, 86_400); // max 1 day; + if block_for_secs > 0 { + let mut connections = self.connections.write().await; + let now = current_time_secs(); + connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); + if let Some(username) = connections + .key_by_addr + .get(addr) + .map(|registration| registration.username.clone()) + .filter(|username| !username.is_empty()) + { + let expires_at = now + block_for_secs; + connections.kicked.insert(username, (expires_at, virtual_id)); + } + } + } + + pub async fn mark_recent_eviction_guard_for_addr( + &self, + addr: &SocketAddr, + protected_addr: SocketAddr, + ttl_secs: u64, + ) { + if ttl_secs == 0 { + return; + } + + let mut connections = self.connections.write().await; + let now = current_time_secs(); + connections.recently_evicted_sessions.retain(|_, protection| protection.expires_at > now); + connections.recent_socket_reentry_guards.retain(|_, protection| protection.expires_at > now); + + let Some(username) = connections + .key_by_addr + .get(addr) + .map(|registration| registration.username.clone()) + .filter(|username| !username.is_empty()) + else { + return; + }; + + let Some(connection_data) = connections.by_key.get(&username) else { + return; + }; + + let protection = RecentWinnerProtection { protected_addr, expires_at: now + ttl_secs }; + let mut session_tokens = Vec::new(); + let mut socket_guard_keys = Vec::new(); + + for stream in connection_data.streams.iter().filter(|stream| stream.addr == *addr) { + if uses_session_reentry_guard(stream) && stream.session_token.is_some() { + let Some(session_token) = stream.session_token.clone() else { + continue; + }; + session_tokens.push(session_token); + } else { + socket_guard_keys.push(create_socket_reentry_guard_key( + &username, + &stream.client_ip, + shared::model::VirtualId::new(stream.channel.virtual_id), + )); + } + } + + for session_token in session_tokens { + connections.recently_evicted_sessions.insert(session_token, protection); + } + for key in socket_guard_keys { + connections.recent_socket_reentry_guards.insert(key, protection); + } + } + + pub async fn get_username_for_addr(&self, addr: &SocketAddr) -> Option { + self.connections.read().await.key_by_addr.get(addr).map(|registration| registration.username.clone()) + } + + fn should_preserve_session_stream(stream: &StreamInfo) -> bool { + stream.session_token.is_some() && is_stable_session_stream(stream) + } + + fn is_preserved_stream_expired(&self, stream: &StreamInfo, sessions: &[UserSession], now: u64) -> bool { + if !stream.preserved || !Self::should_preserve_session_stream(stream) { + return false; + } + + let ttl_secs = self.adaptive_session_ttl_secs.load(Ordering::Relaxed); + let Some(session_token) = stream.session_token.as_deref() else { + return true; + }; + + let session_ts = + sessions.iter().find(|session| session.token == session_token).map_or(stream.ts, |session| session.ts); + + now.saturating_sub(session_ts) >= ttl_secs + } + + fn collect_divergence_snapshot(connection_data: &UserConnectionData, username: &str) -> Option { + log_enabled!(log::Level::Debug).then(|| Self::build_divergence_snapshot(connection_data, username)) + } + + fn build_divergence_snapshot(connection_data: &UserConnectionData, username: &str) -> DivergenceSnapshot { + let connections = connection_data.connections; + let counted_sessions = connection_data.sessions.iter().filter(|s| s.lifecycle.is_counted()).count(); + let streams_count = connection_data.streams.len(); + let mut kinds = Vec::new(); + + for session in &connection_data.sessions { + if !session.lifecycle.is_counted() { + continue; + } + if matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { ref data } if data.reason_code == PendingProviderReason::GraceHold) + { + continue; + } + let has_active_stream = connection_data + .streams + .iter() + .any(|s| s.session_token.as_deref() == Some(&session.token) && !s.preserved); + if !has_active_stream { + kinds.push(DivergenceKind::CountedSessionWithoutStream); + } + } + + for stream in &connection_data.streams { + if stream.preserved { + continue; + } + let Some(token) = stream.session_token.as_deref() else { + continue; + }; + let has_counted_session = + connection_data.sessions.iter().any(|s| s.token == token && s.lifecycle.is_counted()); + if !has_counted_session { + kinds.push(DivergenceKind::StreamWithoutCountedSession); + } + } + + #[allow(clippy::cast_possible_truncation)] + let counted_sessions_u32 = counted_sessions as u32; + if connections != counted_sessions_u32 { + kinds.push(DivergenceKind::ConnectionCountMismatch { legacy: connections, counted: counted_sessions_u32 }); + } + + DivergenceSnapshot { username: username.to_string(), connections, counted_sessions, streams_count, kinds } + } + + async fn log_divergence_snapshot(&self, snapshot: Option) { + let Some(snapshot) = snapshot else { + return; + }; + let cooldown = Duration::from_secs(self.divergence_cooldown_secs); + for kind in &snapshot.kinds { + let key = divergence_key(&snapshot.username, kind); + let should_log = { + let mut cache = self.divergence_cache.lock().await; + if let Some(entry) = cache.get_mut(&key) { + if entry.last_logged.elapsed() >= cooldown { + entry.last_logged = Instant::now(); + entry.count_since_last_log = 0; + true + } else { + entry.count_since_last_log = entry.count_since_last_log.saturating_add(1); + false + } + } else { + cache.push(key, DivergenceEntry { last_logged: Instant::now(), count_since_last_log: 0 }); + true + } + }; + + if should_log { + debug!( + "ADMISSION DIVERGENCE user={} kind={kind:?} connections={} counted_sessions={} streams={}", + snapshot.username, snapshot.connections, snapshot.counted_sessions, snapshot.streams_count, + ); + } + } + } + + async fn check_and_log_divergence_for_user(&self, username: &str) { + let snapshot = { + let connections = self.connections.read().await; + let Some(data) = connections.by_key.get(username) else { + return; + }; + Self::collect_divergence_snapshot(data, username) + }; + self.log_divergence_snapshot(snapshot).await; + } + + async fn run_adaptive_expiry_worker(self: Arc) { + loop { + let next_expiry = { + let queue = self.adaptive_expiry_queue.lock().await; + queue.peek().map(|entry| entry.0.expires_at) + }; + + match next_expiry { + None => { + tokio::select! { + () = self.adaptive_expiry_notify.notified() => {} + () = self.adaptive_expiry_cancel.cancelled() => break, + } + } + Some(expires_at) => { + let now = current_time_secs(); + if expires_at <= now { + self.process_due_adaptive_expiry_entries(now).await; + continue; + } + + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(expires_at.saturating_sub(now))) => {} + () = self.adaptive_expiry_notify.notified() => {} + () = self.adaptive_expiry_cancel.cancelled() => break, + } + } + } + } + } + + #[allow(clippy::too_many_lines)] + async fn process_due_adaptive_expiry_entries(&self, now: u64) { + let mut due_entries = Vec::new(); + { + let mut queue = self.adaptive_expiry_queue.lock().await; + while let Some(entry) = queue.peek() { + if entry.0.expires_at > now { + break; + } + if let Some(Reverse(entry)) = queue.pop() { + due_entries.push(entry); + } + } + } + + if due_entries.is_empty() { + return; + } + + let usernames_to_check: std::collections::HashSet<_> = due_entries.iter().map(|e| &e.username).collect(); + + let mut removed_addrs: Vec = Vec::new(); + let mut cleanup_events: Vec<(std::net::SocketAddr, Box)> = Vec::new(); + let mut replacement_entries: Vec = Vec::new(); + let mut promotions: Vec<(String, PromotionAction)> = Vec::new(); + { + let mut expiry_index = self.adaptive_expiry_index.lock().await; + let mut user_connections = self.connections.write().await; + for entry in &due_entries { + let key = AdaptiveExpiryKey { + username: entry.username.clone(), + session_token: entry.session_token.clone(), + uid: entry.uid, + }; + let Some(current_expires_at) = expiry_index.get(&key).copied() else { + continue; + }; + if current_expires_at != entry.expires_at { + continue; + } + + let mut remove_user = false; + if let Some(connection_data) = user_connections.by_key.get_mut(&entry.username) { + let stream_idx_opt = connection_data.streams.iter().position(|stream| { + stream.uid == entry.uid + && stream.preserved + && stream.session_token.as_deref() == Some(entry.session_token.as_str()) + }); + + if let Some(stream_idx) = stream_idx_opt { + let should_remove = self.is_preserved_stream_expired( + &connection_data.streams[stream_idx], + &connection_data.sessions, + now, + ); + + if should_remove { + let addr = connection_data.streams[stream_idx].addr; + let session_token = connection_data.streams[stream_idx].session_token.clone(); + if self.cleanup_tx.get().is_some() { + cleanup_events.push((addr, Box::new(connection_data.streams[stream_idx].clone()))); + } else { + removed_addrs.push(addr); + } + let removed_stream = connection_data.streams.swap_remove(stream_idx); + if let Some(kind) = connection_data.stream_kinds.remove(&removed_stream.uid) { + connection_data.decrement_kind(kind); + } + connection_data.stream_normal_priorities.remove(&removed_stream.uid); + if let Some(action) = connection_data.try_promote_soft_stream() { + let promoted_stream = + connection_data.streams.iter().find(|stream| stream.uid == action.uid).cloned(); + if let Some(stream) = promoted_stream.as_ref() { + Self::promote_session_for_stream(connection_data, stream); + } + promotions.push((entry.username.clone(), action)); + } + if let Some(session_token) = session_token.as_deref() { + Self::clear_session_counted_without_stream(connection_data, session_token); + } + while connection_data.try_promote_soft_session_reservation() {} + expiry_index.remove(&key); + } else if let Some(replacement_entry) = self.build_preserved_stream_expiry( + &entry.username, + &connection_data.streams[stream_idx], + &connection_data.sessions, + ) { + if replacement_entry.expires_at != current_expires_at { + replacement_entries.push(replacement_entry); + } + } + } else { + expiry_index.remove(&key); + } + + remove_user = connection_data.connections == 0 + && connection_data.streams.is_empty() + && connection_data.sessions.is_empty(); + } else { + expiry_index.remove(&key); + } + + if remove_user { + user_connections.by_key.remove(&entry.username); + } + } + } // locks released here + + // divergence check after adaptive expiry processing + for username in usernames_to_check { + let snapshot = { + let connections = self.connections.read().await; + connections.by_key.get(username).and_then(|data| Self::collect_divergence_snapshot(data, username)) + }; + self.log_divergence_snapshot(snapshot).await; + } + + if let Some(tx) = self.cleanup_tx.get() { + for (addr, stream_info) in cleanup_events { + if tx.try_send(CleanupEvent::AdaptiveSessionExpired { stream_info }).is_err() { + self.dropped_cleanup_events.fetch_add(1, Ordering::Relaxed); + debug!("Cleanup channel unavailable, dropping adaptive session expiry"); + removed_addrs.push(addr); + } + } + } + + for entry in replacement_entries { + self.enqueue_adaptive_expiry(entry).await; + } + + for (username, action) in promotions { + self.emit_promotion_update(&username, action).await; + } + + let had_removals = !removed_addrs.is_empty(); + for addr in removed_addrs { + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Disconnected(addr))); + } + if had_removals { + self.log_active_user().await; + } + } + + fn gc(&self) { + if let Some(gc_ts) = &self.gc_ts { + let ts = gc_ts.load(Ordering::Acquire); + let now = current_time_secs(); + + if now.saturating_sub(ts) > USER_GC_TTL + && gc_ts.compare_exchange(ts, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() + { + if let Ok(mut user_connections) = self.connections.try_write() { + user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); + user_connections.recently_evicted_sessions.retain(|_, protection| protection.expires_at > now); + user_connections.recent_socket_reentry_guards.retain(|_, protection| protection.expires_at > now); + for connection_data in user_connections.by_key.values_mut() { + Self::release_expired_session_reservations(connection_data, now); + connection_data.sessions.retain(|s| now.saturating_sub(s.ts) < USER_CON_TTL); + } + user_connections.by_key.retain(|_k, v| { + v.connections > 0 || !v.streams.is_empty() || now.saturating_sub(v.ts) < USER_CON_TTL + }); + user_connections.key_by_addr.retain(|_, registration| { + !(registration.username.is_empty() && now.saturating_sub(registration.ts) >= ANON_SOCKET_TTL) + }); + } else { + // Lock contention: release the GC claim so a subsequent caller can retry immediately. + let _ = gc_ts.compare_exchange(now, ts, Ordering::AcqRel, Ordering::Relaxed); + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/backend/session/src/active_user_manager/tests.rs b/backend/session/src/active_user_manager/tests.rs new file mode 100644 index 000000000..7593b7197 --- /dev/null +++ b/backend/session/src/active_user_manager/tests.rs @@ -0,0 +1,5673 @@ +use super::*; +use crate::EventManager; +use arc_swap::ArcSwapOption; +use shared::{ + model::{PlaylistItemType, ProxyType, StreamChannel, StreamInfo, XtreamCluster}, + utils::Internable, +}; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; +use tuliprox_core::model::{Config, Fingerprint, ProxyUserCredentials}; + +fn test_channel(virtual_id: u32) -> StreamChannel { + StreamChannel { + target_id: 1, + virtual_id, + provider_id: 1, + input_name: "input".intern(), + item_type: PlaylistItemType::Live, + cluster: XtreamCluster::Live, + group: "group".intern(), + title: "title".intern(), + url: "http://localhost/stream.ts".intern(), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + } +} + +fn test_adaptive_channel(virtual_id: u32) -> StreamChannel { + StreamChannel { + target_id: 1, + virtual_id, + provider_id: 1, + input_name: "input".intern(), + item_type: PlaylistItemType::LiveHls, + cluster: XtreamCluster::Live, + group: "group".intern(), + title: "title".intern(), + url: "http://localhost/stream.ts".intern(), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + } +} + +fn test_series_channel(virtual_id: u32) -> StreamChannel { + StreamChannel { + item_type: PlaylistItemType::Series, + cluster: XtreamCluster::Series, + url: "http://localhost/series/episode.mkv".intern(), + ..test_channel(virtual_id) + } +} + +#[tokio::test] +async fn target_scoped_session_lookup_does_not_use_same_virtual_id_from_other_target() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55499".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "target-scoped-user".to_string(); + let mut target_one = test_channel(42); + target_one.target_id = 1; + let mut target_two = test_channel(42); + target_two.target_id = 2; + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-target-one", + virtual_id: 42, + provider: "provider", + stream_url: "http://localhost/target-one.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-target-two", + virtual_id: 42, + provider: "provider", + stream_url: "http://localhost/target-two.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + { + let mut connections = manager.connections.write().await; + assert!(connections.by_key.contains_key(&user.username), "user connection data should exist"); + let Some(data) = connections.by_key.get_mut(&user.username) else { + return; + }; + data.streams.push(StreamInfo::new(shared::model::StreamInfoParams { + uid: 1, + meter_uid: 1, + username: &user.username, + addr: &addr, + client_ip: "127.0.0.1", + provider: "provider".intern(), + stream_channel: target_one, + user_agent: "ua".to_string(), + country_code: None, + session_token: Some("tok-target-one"), + })); + data.streams.push(StreamInfo::new(shared::model::StreamInfoParams { + uid: 2, + meter_uid: 2, + username: &user.username, + addr: &addr, + client_ip: "127.0.0.1", + provider: "provider".intern(), + stream_channel: target_two, + user_agent: "ua".to_string(), + country_code: None, + session_token: Some("tok-target-two"), + })); + } + + let session = manager.find_latest_session_for_target_stream(&user.username, 2, "input", 42, "tok-target-two").await; + assert!(session.is_some(), "target-scoped session should resolve"); + let Some(session) = session else { + return; + }; + assert_eq!(session.token, "tok-target-two"); + assert!(manager + .find_latest_session_for_target_stream(&user.username, 3, "input", 42, "tok-target-two") + .await + .is_none()); + assert!(manager + .find_latest_session_for_target_stream(&user.username, 1, "input", 42, "tok-target-two") + .await + .is_none()); +} + +/// Session refresh normalizes Expired -> Prepared. +/// When a new request arrives on an expired session, the lifecycle should be +/// reset to Prepared so that full activation evaluation happens. +#[tokio::test] +async fn create_user_session_normalizes_expired_lifecycle() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55400".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "user-lifecycle-refresh".to_string(); + + // Create a session in Expired state directly via session manipulation + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); + data.add_session(UserSession { + token: "tok-refresh-expired".to_string(), + transition_version: 1, + 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], + ts: current_time_secs(), + started_at: current_time_secs(), + permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + lifecycle: PlaybackLifecycle::Expired, + }); + } + + // Refresh the session via create_user_session + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-refresh-expired", + virtual_id: 7001, + 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 sessions = manager.connections.read().await; + let data = sessions.by_key.get(&user.username).expect("user data should exist"); + let session = data.sessions.iter().find(|s| s.token == "tok-refresh-expired").expect("session"); + assert_eq!( + session.lifecycle, + PlaybackLifecycle::Prepared, + "Expired session should normalize to Prepared on refresh" + ); +} + +/// Session refresh does NOT normalize `PendingProvider`. +/// A `PendingProvider` session must not be reset — pending state must continue +/// until explicitly resolved via `activate_pending_provider` or `expire_pending_provider`. +#[tokio::test] +async fn create_user_session_does_not_normalize_pending_provider_lifecycle() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55401".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "user-pending-lifecycle".to_string(); + + // Create a session in PendingProvider state + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); + data.add_session(UserSession { + token: "tok-refresh-pending".to_string(), + transition_version: 1, + 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], + ts: current_time_secs(), + started_at: current_time_secs(), + permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + lifecycle: PlaybackLifecycle::PendingProvider { + data: PendingProviderState { + reason_code: PendingProviderReason::GraceHold, + created_at: current_time_secs(), + deadline: current_time_secs() + 30, + version: 1, + wake_source: None, + }, + }, + }); + } + + // Refresh the session via create_user_session + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-refresh-pending", + virtual_id: 7002, + 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 sessions = manager.connections.read().await; + let data = sessions.by_key.get(&user.username).expect("user data should exist"); + let session = data.sessions.iter().find(|s| s.token == "tok-refresh-pending").expect("session"); + assert!( + matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }), + "PendingProvider session should NOT be normalized on refresh - pending wait must continue" + ); +} + +#[tokio::test] +async fn update_session_provider_headers_updates_existing_session_and_timestamp() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::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::::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::::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() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55410".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "user-terminate".to_string(); + user.max_connections = 2; + + let token = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-test", + virtual_id: 8001, + provider: "provider-terminate", + stream_url: "http://localhost/test.ts", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Verify session exists. + let before = manager.get_and_update_user_session(&user.username, &token).await; + assert!(before.is_some(), "session should exist before terminate"); + assert_eq!(before.as_ref().unwrap().lifecycle, PlaybackLifecycle::Prepared); + + // Terminate the session. + manager.terminate_session(&user.username, &token).await; + + // Session should be gone. + let after = manager.get_and_update_user_session(&user.username, &token).await; + assert!(after.is_none(), "session should be removed after terminate"); +} + +/// `terminate_session` releases counted lease. +#[tokio::test] +async fn terminate_session_releases_counted_lease() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55411".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "user-terminate-counted".to_string(); + user.max_connections = 2; + + let token = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-counted", + virtual_id: 8002, + provider: "provider-terminate-counted", + stream_url: "http://localhost/test.ts", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Mark the session as counted and active (simulating post-admission state). + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.get_mut(&user.username).unwrap(); + let session = data.sessions.iter_mut().find(|s| s.token == token).unwrap(); + // Simulate counted state by setting lifecycle to Active. + session.lifecycle = PlaybackLifecycle::Active; + data.increment_kind(ConnectionKind::Normal); + } + + // Verify counted before terminate. + { + let before = manager.get_and_update_user_session(&user.username, &token).await.unwrap(); + assert!(before.lifecycle.is_counted(), "session should be counted before terminate"); + } + + // Terminate. + manager.terminate_session(&user.username, &token).await; + + // Session should be gone. + let after = manager.get_and_update_user_session(&user.username, &token).await; + assert!(after.is_none(), "session should be removed after terminate"); +} + +#[tokio::test] +async fn terminate_session_removes_preserved_adaptive_stream() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55412".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-terminate-preserved".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-terminate-preserved".to_string(); + user.max_connections = 1; + + let token = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-preserved", + virtual_id: 8003, + provider: "provider-terminate-preserved", + stream_url: "http://localhost/test.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 8003, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-terminate-preserved".intern(), + stream_channel: &test_adaptive_channel(8003), + user_agent: Cow::Borrowed("ua"), + session_token: Some(&token), + }) + .await + .expect("adaptive stream should be registered"); + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty(), "adaptive stream should be preserved first"); + + manager.terminate_session(&user.username, &token).await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user data should remain inspectable"); + assert!(connection_data.streams.is_empty(), "terminating a session must remove its preserved adaptive stream"); + assert!(connection_data.sessions.iter().all(|session| session.token != token)); +} + +#[allow(clippy::too_many_lines)] +#[tokio::test] +async fn terminate_session_promotes_soft_stream_after_releasing_capacity() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let normal_addr: SocketAddr = "127.0.0.1:55413".parse().unwrap(); + let soft_addr: SocketAddr = "127.0.0.1:55414".parse().unwrap(); + let soft_addr_two: SocketAddr = "127.0.0.1:55415".parse().unwrap(); + let normal_fp = Fingerprint::new("fp-terminate-normal".to_string(), "127.0.0.1".to_string(), normal_addr); + let soft_fp = Fingerprint::new("fp-terminate-soft".to_string(), "127.0.0.1".to_string(), soft_addr); + let soft_fp_two = Fingerprint::new("fp-terminate-soft-2".to_string(), "127.0.0.1".to_string(), soft_addr_two); + let mut user = ProxyUserCredentials::default(); + user.username = "user-terminate-promote".to_string(); + user.max_connections = 1; + user.soft_connections = 2; + + manager.add_connection(&normal_addr).await; + manager.add_connection(&soft_addr).await; + manager.add_connection(&soft_addr_two).await; + + let normal_token = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-normal", + virtual_id: 8101, + provider: "provider-normal", + stream_url: "http://localhost/normal.ts", + addr: &normal_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + let soft_token = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-soft", + virtual_id: 8102, + provider: "provider-soft", + stream_url: "http://localhost/soft.ts", + addr: &soft_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Soft), + socket_bound: false, + }) + .await; + let soft_token_two = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-terminate-soft-2", + virtual_id: 8103, + provider: "provider-soft-2", + stream_url: "http://localhost/soft-2.ts", + addr: &soft_addr_two, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Soft), + socket_bound: false, + }) + .await; + + manager + .update_connection(ActiveUserConnectionParams { + uid: 8101, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &normal_fp, + provider: "provider-normal".intern(), + stream_channel: &test_channel(8101), + user_agent: Cow::Borrowed("ua-normal"), + session_token: Some(&normal_token), + }) + .await + .expect("normal stream should be registered"); + manager + .update_connection(ActiveUserConnectionParams { + uid: 8102, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Soft, + priority: -5, + soft_priority: 9, + fingerprint: &soft_fp, + provider: "provider-soft".intern(), + stream_channel: &test_channel(8102), + user_agent: Cow::Borrowed("ua-soft"), + session_token: Some(&soft_token), + }) + .await + .expect("soft stream should be registered"); + manager + .update_connection(ActiveUserConnectionParams { + uid: 8103, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Soft, + priority: -3, + soft_priority: 9, + fingerprint: &soft_fp_two, + provider: "provider-soft-2".intern(), + stream_channel: &test_channel(8103), + user_agent: Cow::Borrowed("ua-soft-2"), + session_token: Some(&soft_token_two), + }) + .await + .expect("second soft stream should be registered"); + + { + let mut connections = manager.connections.write().await; + let connection_data = connections.by_key.get_mut(&user.username).expect("user data should exist"); + connection_data.soft_connections = 1; + } + + manager.terminate_session(&user.username, &normal_token).await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user data should remain inspectable"); + assert_eq!(connection_data.counts.normal, 1); + assert_eq!(connection_data.counts.soft, 1); + let promoted_uid = [8102_u32, 8103_u32] + .into_iter() + .find(|uid| connection_data.stream_kinds.get(uid) == Some(&ConnectionKind::Normal)) + .expect("one soft stream should be promoted to normal"); + let promoted_token = if promoted_uid == 8102 { soft_token.as_str() } else { soft_token_two.as_str() }; + let promoted_session = connection_data + .sessions + .iter() + .find(|session| session.token == promoted_token) + .expect("promoted soft session should remain"); + assert_eq!(promoted_session.connection_kind, Some(ConnectionKind::Normal)); + assert!(matches!(promoted_session.lifecycle, PlaybackLifecycle::Active)); +} + +#[tokio::test] +async fn test_multi_session_same_addr_counts_and_releases_individually() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55001".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key".to_string(), "127.0.0.1".to_string(), addr); + let username = "user1"; + + manager.add_connection(&addr).await; + + let first = manager + .update_connection(ActiveUserConnectionParams { + uid: 1, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(1001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-1"), + }) + .await; + assert!(first.is_some()); + assert_eq!(manager.user_connections(username).await, 1); + assert_eq!(manager.connection_permission(username, 1, 0).await, UserConnectionPermission::Exhausted); + + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 2, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-b".intern(), + stream_channel: &test_channel(1002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-2"), + }) + .await; + assert!(second.is_some()); + assert_eq!(manager.user_connections(username).await, 2); + + assert!(manager.release_stream(&addr).await.is_some()); + assert_eq!(manager.user_connections(username).await, 1); + + assert!(manager.release_stream(&addr).await.is_some()); + assert_eq!(manager.user_connections(username).await, 0); +} + +#[tokio::test] +async fn mark_pending_provider_tracks_metadata_on_session() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55021".parse().unwrap_or_else(|_| unreachable!()); + let mut user = ProxyUserCredentials::default(); + user.username = "pending-user".to_string(); + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending", + virtual_id: 1001, + provider: "provider-a", + stream_url: "http://provider/live/1001.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let _ = + manager.mark_pending_provider(&user.username, "tok-pending", PendingProviderReason::GraceHold, 12_345).await; + + let session = + manager.get_and_update_user_session(&user.username, "tok-pending").await.expect("session should exist"); + let PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else { + panic!("pending provider should be tracked") + }; + assert!(matches!(pending.reason_code, PendingProviderReason::GraceHold)); + assert_eq!(pending.deadline, 12_345); + assert!(pending.created_at > 0); + assert_eq!(pending.version, 1); + assert!(pending.wake_source.is_none()); +} + +#[tokio::test] +async fn activate_pending_provider_clears_pending_metadata() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55022".parse().unwrap_or_else(|_| unreachable!()); + let fingerprint = Fingerprint::new("fp-pending".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = "pending-activate".to_string(); + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending-activate", + virtual_id: 1002, + provider: "provider-a", + stream_url: "http://provider/live/1002.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + let _ = manager + .mark_pending_provider( + &user.username, + "tok-pending-activate", + PendingProviderReason::GraceHold, + current_time_secs().saturating_add(30), + ) + .await; + + let _ = manager + .update_connection(ActiveUserConnectionParams { + uid: 12, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(1002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-pending-activate"), + }) + .await; + manager + .activate_pending_provider(&user.username, "tok-pending-activate", 1, PendingProviderWakeSource::Activated) + .await; + + let session = manager + .get_and_update_user_session(&user.username, "tok-pending-activate") + .await + .expect("session should exist"); + assert!(session.lifecycle.is_counted()); + assert!( + !matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. }), + "explicit pending resolution must clear pending provider state" + ); +} + +#[tokio::test] +async fn activate_pending_provider_ignores_stale_version_after_replacement() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap_or_else(|_| unreachable!()); + let mut user = ProxyUserCredentials::default(); + user.username = "pending-stale".to_string(); + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending-stale", + virtual_id: 1003, + provider: "provider-a", + stream_url: "http://provider/live/1003.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let first_version = manager + .mark_pending_provider(&user.username, "tok-pending-stale", PendingProviderReason::GraceHold, 5_000) + .await + .expect("first pending version should be created"); + let second_version = manager + .mark_pending_provider(&user.username, "tok-pending-stale", PendingProviderReason::GraceHold, 6_000) + .await + .expect("second pending version should replace the first"); + assert!(second_version > first_version); + + manager + .activate_pending_provider( + &user.username, + "tok-pending-stale", + first_version, + PendingProviderWakeSource::CapacityNotify, + ) + .await; + + let session = manager + .get_and_update_user_session(&user.username, "tok-pending-stale") + .await + .expect("session should still exist"); + let PlaybackLifecycle::PendingProvider { data: pending_data } = &session.lifecycle else { + panic!("session should still be in PendingProvider after stale wakeup") + }; + assert_eq!(pending_data.version, second_version); + assert!(pending_data.wake_source.is_none()); + assert_eq!(session.permission, UserConnectionPermission::GracePeriod); +} + +#[tokio::test] +async fn expire_pending_provider_marks_session_exhausted() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap_or_else(|_| unreachable!()); + let mut user = ProxyUserCredentials::default(); + user.username = "pending-expire".to_string(); + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending-expire", + virtual_id: 1004, + provider: "provider-a", + stream_url: "http://provider/live/1004.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let version = manager + .mark_pending_provider(&user.username, "tok-pending-expire", PendingProviderReason::GraceHold, 6_000) + .await + .expect("pending version should be created"); + + manager + .expire_pending_provider(&user.username, "tok-pending-expire", version, PendingProviderWakeSource::Timeout) + .await; + + let session = manager + .get_and_update_user_session(&user.username, "tok-pending-expire") + .await + .expect("session should still exist"); + assert_eq!(session.permission, UserConnectionPermission::Exhausted); + assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. })); + assert!(!session.lifecycle.is_counted()); +} + +#[tokio::test] +async fn expire_pending_provider_releases_counted_slot_for_pending_session() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55025".parse().unwrap_or_else(|_| unreachable!()); + let mut user = ProxyUserCredentials::default(); + user.username = "pending-expire-counted".to_string(); + user.max_connections = 1; + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending-expire-counted", + virtual_id: 1005, + provider: "provider-a", + stream_url: "http://provider/live/1005.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + { + let mut connections = manager.connections.write().await; + let connection_data = + connections.by_key.get_mut(&user.username).expect("session should have created connection data"); + connection_data.increment_kind(ConnectionKind::Normal); + let session = connection_data + .sessions + .iter_mut() + .find(|session| session.token == "tok-pending-expire-counted") + .expect("session should exist"); + // Simulate a previously-counted session transitioning to PendingProvider. + // Set lifecycle to Active (is_counted() = true). The kind count is already + // incremented above via connection_data.increment_kind(). + session.lifecycle = PlaybackLifecycle::Active; + } + + assert_eq!(manager.user_connections(&user.username).await, 1); + + let version = manager + .mark_pending_provider(&user.username, "tok-pending-expire-counted", PendingProviderReason::GraceHold, 6_500) + .await + .expect("pending version should be created"); + + manager + .expire_pending_provider( + &user.username, + "tok-pending-expire-counted", + version, + PendingProviderWakeSource::Timeout, + ) + .await; + + let session = manager + .get_and_update_user_session(&user.username, "tok-pending-expire-counted") + .await + .expect("session should still exist"); + assert_eq!(session.permission, UserConnectionPermission::Exhausted); + assert!(!matches!(session.lifecycle, PlaybackLifecycle::PendingProvider { .. })); + assert!(!session.lifecycle.is_counted()); + assert_eq!(manager.user_connections(&user.username).await, 0); +} + +/// `terminate_sessions_for_addr` expires all sessions at a given addr and releases counted leases. +#[tokio::test] +async fn terminate_sessions_for_addr_expires_all_sessions_at_addr() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr_kick: SocketAddr = "127.0.0.1:55420".parse().unwrap(); + let addr_keep: SocketAddr = "127.0.0.1:55421".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "user-kick-addr".to_string(); + user.max_connections = 4; + + // Create session at kicked addr. + let tok_kick = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-kick", + virtual_id: 1, + provider: "provider-a", + stream_url: "http://provider/live/1.m3u8", + addr: &addr_kick, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Create session at kept addr. + let tok_keep = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-keep", + virtual_id: 2, + provider: "provider-b", + stream_url: "http://provider/live/2.m3u8", + addr: &addr_keep, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Mark both sessions as counted and active. + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.get_mut(&user.username).unwrap(); + for session in &mut data.sessions { + // Simulate counted state by setting lifecycle to Active. + session.lifecycle = PlaybackLifecycle::Active; + } + data.increment_kind(ConnectionKind::Normal); + data.increment_kind(ConnectionKind::Normal); + } + + assert_eq!(manager.user_connections(&user.username).await, 2); + + // Kick the addr — should terminate only the sessions at that addr. + manager.terminate_sessions_for_addr(&user.username, &addr_kick).await; + + // Session at kicked addr should be gone. + assert!( + manager.get_and_update_user_session(&user.username, &tok_kick).await.is_none(), + "kicked session should be removed" + ); + + // Session at kept addr should remain. + let kept = + manager.get_and_update_user_session(&user.username, &tok_keep).await.expect("kept session should still exist"); + assert_eq!(kept.token, tok_keep); + assert_eq!(kept.addr, addr_keep); + + // Connection count should drop by 1. + assert_eq!(manager.user_connections(&user.username).await, 1); +} + +#[tokio::test] +async fn test_grant_grace_succeeds_at_and_above_limit_without_prior_grace() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let at_limit_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap(); + let at_limit_fingerprint = Fingerprint::new("fp-limit".to_string(), "127.0.0.1".to_string(), at_limit_addr); + let over_limit_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap(); + let over_limit_fingerprint = Fingerprint::new("fp-over".to_string(), "127.0.0.1".to_string(), over_limit_addr); + + manager.add_connection(&at_limit_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 10, + meter_uid: 0, + username: "at-limit", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &at_limit_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(1010), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-limit"), + }) + .await; + + assert!(manager.grant_grace("at-limit").await); + + manager.add_connection(&over_limit_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 11, + meter_uid: 0, + username: "over-limit", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &over_limit_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(1011), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-over-1"), + }) + .await; + manager.add_connection(&"127.0.0.1:55013".parse().unwrap()).await; + let second_fingerprint = + Fingerprint::new("fp-over-2".to_string(), "127.0.0.1".to_string(), "127.0.0.1:55013".parse().unwrap()); + manager + .update_connection(ActiveUserConnectionParams { + uid: 12, + meter_uid: 0, + username: "over-limit", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &second_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(1012), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-over-2"), + }) + .await; + + assert!(manager.grant_grace("over-limit").await); +} + +fn test_user_credentials(username: &str, max_connections: u32, soft_connections: u16) -> ProxyUserCredentials { + ProxyUserCredentials { + username: username.to_string(), + password: "test".to_string(), + token: None, + proxy: ProxyType::default(), + server: None, + epg_timeshift: None, + epg_request_timeshift: None, + created_at: None, + exp_date: None, + max_connections, + status: None, + output_clusters: shared::model::ClusterFlags::all(), + ui_enabled: true, + comment: None, + priority: 0, + soft_connections, + soft_priority: 0, + t_is_api_user: false, + network_access: None, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 0, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::default()), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, + } +} + +fn record_owned_slot(counts: &mut UserConnectionCounts, kind: ConnectionKind) { + match kind { + ConnectionKind::Normal => counts.normal += 1, + ConnectionKind::Soft => counts.soft += 1, + } +} + +fn assert_connection_ownership_invariants(connection_data: &UserConnectionData) { + let mut owned_slots = UserConnectionCounts::default(); + + // A counted session owns one logical slot. Active streams tied to that + // session validate its kind below, but do not add another slot. + for session in connection_data.sessions.iter().filter(|session| session.lifecycle.is_counted()) { + record_owned_slot(&mut owned_slots, session.connection_kind.unwrap_or(ConnectionKind::Normal)); + } + + for stream in &connection_data.streams { + let stream_kind = connection_data.stream_kinds.get(&stream.uid); + if stream.preserved { + assert!(stream_kind.is_none(), "preserved stream {} must not own a real connection slot", stream.uid); + continue; + } + + let stream_kind = stream_kind.expect("every active stream must have a connection kind"); + let counted_session = stream.session_token.as_deref().and_then(|session_token| { + connection_data + .sessions + .iter() + .find(|session| session.token == session_token && session.lifecycle.is_counted()) + }); + if let Some(session) = counted_session { + assert_eq!( + *stream_kind, + session.connection_kind.unwrap_or(ConnectionKind::Normal), + "a counted session and its active stream must use the same slot kind" + ); + } else { + record_owned_slot(&mut owned_slots, *stream_kind); + } + } + + for uid in connection_data.stream_kinds.keys() { + assert!( + connection_data.streams.iter().any(|stream| stream.uid == *uid && !stream.preserved), + "stream kind for uid {uid} must belong to an active stream" + ); + } + + assert_eq!(connection_data.counts.normal, owned_slots.normal, "normal slots must match their owners"); + assert_eq!(connection_data.counts.soft, owned_slots.soft, "soft slots must match their owners"); + assert_eq!( + connection_data.connections, + connection_data.counts.normal + u32::from(connection_data.counts.soft), + "aggregate connection count must equal the normal and soft counters" + ); +} + +fn assert_no_real_connection_slots(connection_data: &UserConnectionData) { + assert_eq!(connection_data.connections, 0); + assert_eq!(connection_data.counts.normal, 0); + assert_eq!(connection_data.counts.soft, 0); + assert_connection_ownership_invariants(connection_data); +} + +fn assert_active_stream_kind(connection_data: &UserConnectionData, stream_uid: u32, expected_kind: ConnectionKind) { + assert!( + connection_data.streams.iter().any(|stream| stream.uid == stream_uid && !stream.preserved), + "stream {stream_uid} must remain active" + ); + assert_eq!(connection_data.stream_kinds.get(&stream_uid), Some(&expected_kind)); +} + +fn assert_single_normal_stream_slot(connection_data: &UserConnectionData, stream_uid: u32) { + assert_eq!(connection_data.connections, 1); + assert_eq!(connection_data.counts.normal, 1); + assert_eq!(connection_data.counts.soft, 0); + assert_active_stream_kind(connection_data, stream_uid, ConnectionKind::Normal); + assert_connection_ownership_invariants(connection_data); +} + +fn assert_preserved_session_is_uncounted(connection_data: &UserConnectionData, session_token: &str, stream_uid: u32) { + let session = connection_data + .sessions + .iter() + .find(|session| session.token == session_token) + .expect("preserved session must exist"); + assert_eq!(session.lifecycle, PlaybackLifecycle::Preserved); + + let stream = + connection_data.streams.iter().find(|stream| stream.uid == stream_uid).expect("preserved stream must exist"); + assert!(stream.preserved); + assert_eq!(stream.session_token.as_deref(), Some(session_token)); + assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); +} + +async fn commit_and_preserve_adaptive_session( + manager: &ActiveUserManager, + user: &ProxyUserCredentials, + session_token: &str, + stream_uid: u32, + addr: SocketAddr, + connection_kind: ConnectionKind, +) { + let fingerprint = Fingerprint::new(format!("fp-preserved-{stream_uid}"), addr.ip().to_string(), addr); + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user, + session_token, + virtual_id: stream_uid, + provider: "provider-a", + stream_url: "http://localhost/live-preserved.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(connection_kind), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: stream_uid, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(stream_uid), + user_agent: Cow::Borrowed("ua"), + session_token: Some(session_token), + }) + .await + .expect("adaptive session stream should bind"); + + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + let session = connection_data + .sessions + .iter() + .find(|session| session.token == session_token) + .expect("committed session must exist"); + assert_eq!(session.lifecycle, PlaybackLifecycle::Active); + assert_eq!(connection_data.stream_kinds.get(&stream_uid), Some(&connection_kind)); + assert_connection_ownership_invariants(connection_data); + } + + assert!(manager.release_stream(&addr).await.is_none(), "adaptive stream should be preserved"); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); + assert_connection_ownership_invariants(connection_data); +} + +#[tokio::test] +async fn eviction_candidates_ignore_ambiguous_socket_addrs() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let shared_addr: SocketAddr = "127.0.0.1:55031".parse().unwrap(); + let unique_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap(); + let shared_fp = Fingerprint::new("fp-shared".to_string(), "127.0.0.1".to_string(), shared_addr); + let unique_fp = Fingerprint::new("fp-unique".to_string(), "127.0.0.1".to_string(), unique_addr); + + manager.add_connection(&shared_addr).await; + manager.add_connection(&unique_addr).await; + + // Create sessions first so update_connection can mark them as counted. + let user = test_user_credentials("same-user", 3, 0); + for (token, addr, channel_id) in + [("tok-31", shared_addr, 1031u32), ("tok-32", shared_addr, 1032), ("tok-33", unique_addr, 1033)] + { + manager + .create_user_session(crate::CreateUserSessionParams { + user: &user, + session_token: token, + virtual_id: channel_id, + provider: "provider-a", + stream_url: "", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + } + + // update_connection marks the session as counted. + for (uid, token, fp, channel_id) in [(31, "tok-31", &shared_fp, 1031u32), (32, "tok-32", &shared_fp, 1032)] { + manager + .update_connection(ActiveUserConnectionParams { + uid, + meter_uid: 0, + username: "same-user", + max_connections: 3, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: fp, + provider: "provider-a".intern(), + stream_channel: &test_channel(channel_id), + user_agent: Cow::Borrowed("ua"), + session_token: Some(token), + }) + .await; + } + + manager + .update_connection(ActiveUserConnectionParams { + uid: 33, + meter_uid: 0, + username: "same-user", + max_connections: 3, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &unique_fp, + provider: "provider-a".intern(), + stream_channel: &test_channel(1033), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-33"), + }) + .await; + + let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await; + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].addr, unique_addr); +} + +#[tokio::test] +async fn eviction_candidates_include_other_ips_for_user_wide_rules() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let first_addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); + let second_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap(); + let first_fp = Fingerprint::new("fp-user-wide-1".to_string(), "10.0.0.1".to_string(), first_addr); + let second_fp = Fingerprint::new("fp-user-wide-2".to_string(), "10.0.0.2".to_string(), second_addr); + + manager.add_connection(&first_addr).await; + manager.add_connection(&second_addr).await; + + let user = test_user_credentials("same-user", 2, 0); + for (token, addr, channel_id) in [("tok-41", first_addr, 1041u32), ("tok-42", second_addr, 1042)] { + manager + .create_user_session(crate::CreateUserSessionParams { + user: &user, + session_token: token, + virtual_id: channel_id, + provider: "provider-a", + stream_url: "", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + } + + manager + .update_connection(ActiveUserConnectionParams { + uid: 41, + meter_uid: 0, + username: "same-user", + max_connections: 2, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &first_fp, + provider: "provider-a".intern(), + stream_channel: &test_channel(1041), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-41"), + }) + .await; + + manager + .update_connection(ActiveUserConnectionParams { + uid: 42, + meter_uid: 0, + username: "same-user", + max_connections: 2, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &second_fp, + provider: "provider-a".intern(), + stream_channel: &test_channel(1042), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-42"), + }) + .await; + + let candidates = manager.get_eviction_candidates("same-user", "10.0.0.1").await; + assert_eq!(candidates.len(), 2); + assert!(candidates.iter().any(|candidate| candidate.addr == first_addr)); + assert!(candidates.iter().any(|candidate| candidate.addr == second_addr)); +} + +#[tokio::test] +async fn eviction_candidates_include_preserved_adaptive_streams() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55043".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-preserved".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("same-user"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-preserved", + virtual_id: 1043, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 43, + meter_uid: 0, + username: "same-user", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(1043) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-preserved"), + }) + .await; + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty(), "adaptive stream should stay logically active"); + + let candidates = manager.get_eviction_candidates("same-user", "127.0.0.1").await; + assert_eq!(candidates.len(), 1, "preserved adaptive streams must remain evictable"); + assert_eq!(candidates[0].addr, addr); +} + +#[tokio::test] +async fn test_kicked_release_does_not_preserve_adaptive_stream() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55014".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-adaptive".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-adaptive"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-adaptive", + virtual_id: 2014, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 14, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(2014), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-adaptive"), + }) + .await; + + let removed = manager.release_connection_as_kicked(&addr).await; + assert!(removed.addr_removed); + assert_eq!(removed.removed_streams.len(), 1); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn kicked_release_removes_preserved_adaptive_stream_without_socket_registration() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-preserved-kick".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-preserved-kick"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-preserved-kick", + virtual_id: 2017, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 17, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(2017), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-preserved-kick"), + }) + .await; + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty()); + assert!(manager.active_streams().await.is_empty()); + + let kicked = manager.release_connection_as_kicked(&addr).await; + assert!(kicked.addr_removed); + assert_eq!(kicked.removed_streams.len(), 1); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn test_kicked_release_invalidates_removed_session_tokens() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let kicked_addr: SocketAddr = "127.0.0.1:55015".parse().unwrap(); + let survivor_addr: SocketAddr = "127.0.0.1:55016".parse().unwrap(); + let kicked_fingerprint = Fingerprint::new("fp-kicked".to_string(), "127.0.0.1".to_string(), kicked_addr); + let survivor_fingerprint = Fingerprint::new("fp-survivor".to_string(), "127.0.0.1".to_string(), survivor_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("kicked-user"); + user.max_connections = 1; + + manager.add_connection(&kicked_addr).await; + manager.add_connection(&survivor_addr).await; + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-kicked", + virtual_id: 2015, + provider: "provider-a", + stream_url: "http://localhost/live-1.ts", + addr: &kicked_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: true, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-survivor", + virtual_id: 2016, + provider: "provider-a", + stream_url: "http://localhost/live-2.ts", + addr: &survivor_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: true, + }) + .await; + + manager + .update_connection(ActiveUserConnectionParams { + uid: 15, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &kicked_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(2015), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-kicked"), + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 16, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &survivor_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(2016), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-survivor"), + }) + .await; + + let removed = manager.release_connection_as_kicked(&kicked_addr).await; + assert!(removed.addr_removed); + assert_eq!(removed.removed_streams.len(), 1); + assert_eq!( + manager.connection_admission_for_session(&user.username, 1, 0, "tok-kicked").await.permission, + UserConnectionPermission::Exhausted + ); + assert_eq!( + manager.connection_admission_for_session(&user.username, 1, 0, "tok-survivor").await.permission, + UserConnectionPermission::Allowed + ); +} + +#[tokio::test] +async fn test_grace_at_limit_remains_active_until_connections_drop_below_limit() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55017".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-grace".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 17, + meter_uid: 0, + username: "grace-at-limit", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(2017), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-grace"), + }) + .await; + + assert!(manager.grant_grace("grace-at-limit").await); + assert_eq!( + manager.connection_admission("grace-at-limit", 1, 0).await.permission, + UserConnectionPermission::Exhausted + ); + assert!(!manager.grant_grace("grace-at-limit").await); +} + +#[tokio::test] +async fn test_same_session_token_on_new_addr_reuses_logical_connection() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let first_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap(); + let second_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap(); + let first = Fingerprint::new("fp-key-1".to_string(), "127.0.0.1".to_string(), first_addr); + let second = Fingerprint::new("fp-key-2".to_string(), "127.0.0.1".to_string(), second_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&first_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls", + virtual_id: 2001, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 0, + meter_uid: 0, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &first, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(2001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-hls"), + }) + .await; + + assert_eq!( + manager.connection_permission_for_session("user1", 1, 0, "tok-hls").await, + UserConnectionPermission::Allowed + ); + + manager.add_connection(&second_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 0, + meter_uid: 0, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &second, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(2001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-hls"), + }) + .await; + + assert_eq!(manager.user_connections("user1").await, 1); + + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].addr, second_addr); + assert_eq!(streams[0].session_token.as_deref(), Some("tok-hls")); +} + +#[tokio::test] +async fn adaptive_session_stream_cleanup_addrs_excludes_manifest_addr_and_current_addr() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let manifest_addr: SocketAddr = "127.0.0.1:55091".parse().unwrap(); + let first_segment_addr: SocketAddr = "10.41.41.89:55092".parse().unwrap(); + let next_segment_addr: SocketAddr = "10.41.41.89:55093".parse().unwrap(); + let first_segment = Fingerprint::new("fp-segment-1".to_string(), "10.41.41.89".to_string(), first_segment_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&manifest_addr).await; + manager.add_connection(&first_segment_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-cleanup", + virtual_id: 2002, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &manifest_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 0, + meter_uid: 0, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &first_segment, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(2002) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-hls-cleanup"), + }) + .await; + + assert_eq!( + manager.adaptive_session_stream_cleanup_addrs("user1", "tok-hls-cleanup", &next_segment_addr).await, + vec![first_segment_addr] + ); +} + +#[tokio::test] +async fn adaptive_session_stream_cleanup_addrs_falls_back_to_same_ip_session_addrs() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let manifest_addr: SocketAddr = "127.0.0.1:55101".parse().unwrap(); + let first_segment_addr: SocketAddr = "10.41.41.89:55102".parse().unwrap(); + let next_segment_addr: SocketAddr = "10.41.41.89:55103".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user2"); + user.max_connections = 1; + + manager.add_connection(&manifest_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-cleanup-fallback", + virtual_id: 2003, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &manifest_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &first_segment_addr).await; + manager.update_session_addr("user2", "tok-hls-cleanup-fallback", &next_segment_addr).await; + + assert_eq!( + manager.adaptive_session_stream_cleanup_addrs("user2", "tok-hls-cleanup-fallback", &next_segment_addr).await, + vec![first_segment_addr] + ); +} + +#[tokio::test] +async fn recently_evicted_session_guard_survives_ttl_while_protected_addr_is_still_active() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let evicted_addr: SocketAddr = "127.0.0.1:55111".parse().unwrap(); + let protected_addr: SocketAddr = "127.0.0.1:55112".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-guard-session".to_string(), "127.0.0.1".to_string(), evicted_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("guard-user"); + user.max_connections = 1; + + manager.add_connection(&evicted_addr).await; + manager.add_connection(&protected_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-guard-session", + virtual_id: 2018, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &evicted_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 18, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(2018), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-guard-session"), + }) + .await; + + manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 1).await; + { + let mut connections = manager.connections.write().await; + if let Some(registration) = connections.key_by_addr.get_mut(&protected_addr) { + registration.username = user.username.clone(); + } + let protection = connections + .recently_evicted_sessions + .get_mut("tok-guard-session") + .expect("recent eviction guard should exist"); + protection.expires_at = current_time_secs().saturating_sub(1); + } + + assert_eq!(manager.recently_evicted_session_protected_addr("tok-guard-session").await, Some(protected_addr)); +} + +#[tokio::test] +async fn recently_evicted_vod_uses_session_reentry_guard() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let evicted_addr: SocketAddr = "127.0.0.1:55113".parse().unwrap(); + let protected_addr: SocketAddr = "127.0.0.1:55114".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-vod-guard".to_string(), "127.0.0.1".to_string(), evicted_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("vod-guard-user"); + user.max_connections = 1; + let mut channel = test_channel(2019); + channel.item_type = PlaylistItemType::Video; + channel.cluster = XtreamCluster::Video; + channel.url = "http://localhost/movie.mkv".intern(); + + manager.add_connection(&evicted_addr).await; + manager.add_connection(&protected_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-guard-vod", + virtual_id: channel.virtual_id, + provider: "provider-a", + stream_url: channel.url.as_ref(), + addr: &evicted_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 19, + meter_uid: 0, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-guard-vod"), + }) + .await; + + manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10).await; + + assert_eq!(manager.recently_evicted_session_protected_addr("tok-guard-vod").await, Some(protected_addr)); + let connections = manager.connections.read().await; + assert!( + connections.recent_socket_reentry_guards.is_empty(), + "provider-affine VOD must not be guarded by transient socket identity" + ); +} + +#[tokio::test] +async fn provider_affine_stream_without_session_token_uses_socket_reentry_fallback() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let evicted_addr: SocketAddr = "127.0.0.1:55115".parse().unwrap(); + let protected_addr: SocketAddr = "127.0.0.1:55116".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-vod-no-token".to_string(), "127.0.0.1".to_string(), evicted_addr); + let mut channel = test_channel(2020); + channel.item_type = PlaylistItemType::Video; + channel.cluster = XtreamCluster::Video; + channel.url = "http://localhost/movie-no-token.mkv".intern(); + + manager.add_connection(&evicted_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 20, + meter_uid: 0, + username: "vod-no-token-user", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await; + + manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10).await; + + assert_eq!( + manager + .recent_socket_reentry_protected_addr( + "vod-no-token-user", + "127.0.0.1", + shared::model::VirtualId::new(channel.virtual_id) + ) + .await, + Some(protected_addr) + ); +} + +#[tokio::test] +async fn test_reused_logical_stream_refreshes_normal_priority() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55023".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-2a".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-prio", + virtual_id: 2002, + provider: "provider-a", + stream_url: "http://localhost/live-prio.ts", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Soft), + socket_bound: true, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 201, + meter_uid: 0, + username: "user1", + max_connections: 1, + soft_connections: 1, + connection_kind: ConnectionKind::Soft, + priority: 8, + soft_priority: 8, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(2002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-prio"), + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 201, + meter_uid: 0, + username: "user1", + max_connections: 1, + soft_connections: 1, + connection_kind: ConnectionKind::Soft, + priority: -7, + soft_priority: 8, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(2002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-prio"), + }) + .await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get("user1").unwrap(); + assert_eq!(connection_data.stream_normal_priorities.get(&201), Some(&-7)); +} + +#[tokio::test] +async fn test_same_session_token_refreshes_meter_metadata_on_reuse() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55031".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-3".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + let first = manager + .update_connection(ActiveUserConnectionParams { + uid: 11, + meter_uid: 101, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(3001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-meter"), + }) + .await + .expect("initial stream should register"); + assert_eq!(first.uid, 11); + assert_eq!(first.meter_uid, 101); + + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 22, + meter_uid: 202, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-b".intern(), + stream_channel: &test_adaptive_channel(3002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-meter"), + }) + .await + .expect("reused stream should register"); + + assert_eq!(second.uid, 11, "logical stream identity should stay stable on session reuse"); + assert_eq!(second.meter_uid, 202, "reused stream must refresh its meter mapping"); + + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].uid, 11); + assert_eq!(streams[0].meter_uid, 202); + assert_eq!(streams[0].provider.as_ref(), "provider-b"); + assert_eq!(streams[0].channel.virtual_id, 3002); +} + +#[tokio::test] +async fn socket_bound_live_streams_with_colliding_token_are_tracked_separately() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let Some(addr) = "127.0.0.1:55032".parse::().ok() else { + return; + }; + let fingerprint = Fingerprint::new("fp-key-colliding".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + let first = manager + .update_connection(ActiveUserConnectionParams { + uid: 31, + meter_uid: 301, + username: "user1", + max_connections: 0, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(3003), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-live-colliding"), + }) + .await; + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 32, + meter_uid: 302, + username: "user1", + max_connections: 0, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-b".intern(), + stream_channel: &test_channel(3003), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-live-colliding"), + }) + .await; + + assert!(first.is_some()); + assert!(second.is_some()); + + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 2); + assert!(streams.iter().any(|stream| stream.uid == 31)); + assert!(streams.iter().any(|stream| stream.uid == 32)); +} + +#[tokio::test] +async fn unlimited_user_can_open_same_and_different_live_streams_from_same_ip() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let username = "unlimited-same-ip"; + let client_ip = "10.9.0.1"; + let addrs = [ + "10.9.0.1:55101".parse::().unwrap(), + "10.9.0.1:55102".parse::().unwrap(), + "10.9.0.1:55103".parse::().unwrap(), + ]; + let fingerprints = [ + Fingerprint::new("fp-unlimited-1".to_string(), client_ip.to_string(), addrs[0]), + Fingerprint::new("fp-unlimited-2".to_string(), client_ip.to_string(), addrs[1]), + Fingerprint::new("fp-unlimited-3".to_string(), client_ip.to_string(), addrs[2]), + ]; + + for addr in addrs { + manager.add_connection(&addr).await; + } + + for (idx, (fingerprint, virtual_id)) in fingerprints.iter().zip([4100, 4100, 4101]).enumerate() { + let token = format!("tok-unlimited-{idx}"); + manager + .update_connection(ActiveUserConnectionParams { + uid: 410 + u32::try_from(idx).unwrap_or_default(), + meter_uid: 0, + username, + max_connections: 0, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(virtual_id), + user_agent: Cow::Borrowed("ua"), + session_token: Some(&token), + }) + .await + .expect("unlimited stream should register"); + } + + assert_eq!(manager.user_connections(username).await, 3); + assert_eq!(manager.active_streams().await.len(), 3); + assert_eq!(manager.connection_admission(username, 0, 0).await.permission, UserConnectionPermission::Allowed); + assert_eq!( + manager.connection_admission_for_session(username, 0, 0, "tok-unlimited-new").await.permission, + UserConnectionPermission::Allowed + ); +} + +#[tokio::test] +async fn release_stream_by_uid_removes_only_matching_stream_on_shared_addr() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let Some(addr) = "127.0.0.1:55033".parse::().ok() else { + return; + }; + let fingerprint = Fingerprint::new("fp-key-shared-addr".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + for uid in [41, 42] { + manager + .update_connection(ActiveUserConnectionParams { + uid, + meter_uid: uid + 300, + username: "user1", + max_connections: 0, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(3004), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-live-shared-addr"), + }) + .await; + } + + let removed = manager.release_stream_by_uid(&addr, 42).await; + assert!(removed.as_ref().is_some_and(|stream| stream.uid == 42)); + + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].uid, 41); +} + +#[tokio::test] +async fn release_stream_by_uid_finds_original_user_after_shared_addr_owner_changes() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55034".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-cross-user-stream".to_string(), "127.0.0.1".to_string(), addr); + manager.add_connection(&addr).await; + + for (uid, username) in [(43, "user-a"), (44, "user-b")] { + manager + .update_connection(ActiveUserConnectionParams { + uid, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_series_channel(3005), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("direct Series stream should register"); + } + + assert_eq!(manager.active_users_and_connections().await, (2, 2)); + assert_eq!(manager.active_streams().await.len(), 2); + + let removed = manager.release_stream_by_uid(&addr, 43).await; + assert!(removed.as_ref().is_some_and(|stream| stream.uid == 43)); + assert_eq!(manager.active_users_and_connections().await, (1, 1)); + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].uid, 44); + + assert!(manager.release_stream_by_uid(&addr, 44).await.is_some()); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn release_connection_cleans_every_user_stream_for_reused_addr_only() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let reused_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap(); + let unrelated_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap(); + let reused_fingerprint = Fingerprint::new("fp-cross-user-socket".to_string(), "127.0.0.1".to_string(), reused_addr); + let unrelated_fingerprint = + Fingerprint::new("fp-unrelated-socket".to_string(), "127.0.0.1".to_string(), unrelated_addr); + manager.add_connection(&reused_addr).await; + manager.add_connection(&unrelated_addr).await; + + for (uid, username, fingerprint) in [ + (45, "user-a", &reused_fingerprint), + (46, "user-b", &reused_fingerprint), + (47, "user-a", &unrelated_fingerprint), + ] { + manager + .update_connection(ActiveUserConnectionParams { + uid, + meter_uid: 0, + username, + max_connections: 2, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_series_channel(3006 + uid), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("direct Series stream should register"); + } + + let released = manager.release_connection(&reused_addr).await; + let mut removed_uids = released.removed_streams.iter().map(|stream| stream.uid).collect::>(); + removed_uids.sort_unstable(); + assert!(released.addr_removed); + assert_eq!(removed_uids, vec![45, 46]); + assert_eq!(manager.active_users_and_connections().await, (1, 1)); + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].uid, 47); + + manager.release_connection(&unrelated_addr).await; + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn connection_counts_are_broadcast_when_active_user_logging_is_disabled() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55037".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-count-events".to_string(), "127.0.0.1".to_string(), addr); + manager.add_connection(&addr).await; + manager.release_connection(&addr).await; + assert!( + tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(), + "closing an unowned socket must not broadcast unchanged connection counts" + ); + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 48, + meter_uid: 0, + username: "event-user", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_series_channel(3048), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("direct Series stream should register"); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .expect("connection count update should be broadcast") + .expect("event channel should remain open"), + EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(1, 1)) + ); + + manager + .update_connection(ActiveUserConnectionParams { + uid: 49, + meter_uid: 0, + username: "event-user", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_series_channel(3048), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("same direct Series stream should be reused"); + assert!( + tokio::time::timeout(Duration::from_millis(50), events.recv()).await.is_err(), + "unchanged connection counts must not broadcast another full snapshot" + ); + + manager.release_stream_by_uid(&addr, 48).await.expect("direct Series stream should release"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .expect("released count update should be broadcast") + .expect("event channel should remain open"), + EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0)) + ); +} + +#[tokio::test] +async fn test_adaptive_session_release_connection_preserves_logical_stream_and_start_time() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); + let next_addr: SocketAddr = "127.0.0.1:55042".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-4".to_string(), "127.0.0.1".to_string(), addr); + let next_fingerprint = Fingerprint::new("fp-key-5".to_string(), "127.0.0.1".to_string(), next_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls", + virtual_id: 4001, + 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 first = manager + .update_connection(ActiveUserConnectionParams { + uid: 44, + meter_uid: 144, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(4001) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-hls"), + }) + .await + .expect("initial adaptive session should register"); + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty(), "adaptive session should remain logically active"); + assert_eq!(manager.user_connections("user1").await, 0); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + assert!(manager.active_streams().await.is_empty()); + + let connections = manager.connections.read().await; + let preserved_stream = connections + .by_key + .get("user1") + .and_then(|data| data.streams.iter().find(|stream| stream.uid == 44)) + .expect("preserved adaptive stream should stay internally tracked"); + assert_eq!(preserved_stream.ts, first.ts); + assert!(preserved_stream.preserved); + drop(connections); + + manager.add_connection(&next_addr).await; + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 55, + meter_uid: 155, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &next_fingerprint, + provider: "provider-b".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(4002) }, + user_agent: Cow::Borrowed("ua-2"), + session_token: Some("tok-hls"), + }) + .await + .expect("adaptive session should reuse logical stream"); + + assert_eq!(second.uid, 44); + assert_eq!(second.ts, first.ts, "adaptive session duration must stay session-based"); + assert_eq!(second.addr, next_addr); + assert_eq!(second.meter_uid, 155); + assert_eq!(manager.user_connections("user1").await, 1); + + let streams = manager.active_streams().await; + assert_eq!(streams.len(), 1); + assert!(!streams[0].preserved); +} + +#[tokio::test] +async fn test_release_stream_ignores_preserved_adaptive_entry() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55051".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-6".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls", + virtual_id: 5001, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 66, + meter_uid: 166, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(5001) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-hls"), + }) + .await; + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty()); + assert!(manager.release_stream(&addr).await.is_none()); +} + +#[tokio::test] +async fn test_preserved_adaptive_stream_is_pruned_after_session_ttl() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55061".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-7".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-expire", + virtual_id: 6001, + provider: "provider-a", + stream_url: "http://localhost/hls.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 77, + meter_uid: 177, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(6001) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-expire"), + }) + .await; + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + + { + let mut connections = manager.connections.write().await; + let connection_data = connections.by_key.get_mut("user1").unwrap(); + let session = connection_data.sessions.iter_mut().find(|session| session.token == "tok-expire").unwrap(); + session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); + } + if let Some(gc_ts) = &manager.gc_ts { + gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); + } + + manager + .process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1)) + .await; + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn test_due_adaptive_expiry_removal_promotes_soft_stream() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let normal_addr: SocketAddr = "127.0.0.1:55062".parse().unwrap(); + let soft_addr: SocketAddr = "127.0.0.1:55063".parse().unwrap(); + let normal_fp = Fingerprint::new("fp-key-7a".to_string(), "127.0.0.1".to_string(), normal_addr); + let soft_fp = Fingerprint::new("fp-key-7b".to_string(), "127.0.0.1".to_string(), soft_addr); + + manager.add_connection(&normal_addr).await; + manager.add_connection(&soft_addr).await; + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + user.soft_connections = 1; + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-expire-normal", + virtual_id: 6002, + provider: "provider-a", + stream_url: "http://localhost/hls-normal.m3u8", + addr: &normal_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 78, + meter_uid: 178, + username: "user1", + max_connections: 1, + soft_connections: 1, + connection_kind: ConnectionKind::Normal, + priority: -1, + soft_priority: 9, + fingerprint: &normal_fp, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(6002) }, + user_agent: Cow::Borrowed("ua-normal"), + session_token: Some("tok-expire-normal"), + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 79, + meter_uid: 179, + username: "user1", + max_connections: 1, + soft_connections: 1, + connection_kind: ConnectionKind::Soft, + priority: -5, + soft_priority: 9, + fingerprint: &soft_fp, + provider: "provider-a".intern(), + stream_channel: &test_channel(6003), + user_agent: Cow::Borrowed("ua-soft"), + session_token: None, + }) + .await; + + let released = manager.release_connection(&normal_addr).await; + assert!(released.addr_removed); + + { + let mut connections = manager.connections.write().await; + let connection_data = connections.by_key.get_mut("user1").unwrap(); + let session = connection_data.sessions.iter_mut().find(|session| session.token == "tok-expire-normal").unwrap(); + session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); + } + + manager + .process_due_adaptive_expiry_entries(current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1)) + .await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get("user1").unwrap(); + assert_eq!(connection_data.stream_kinds.get(&79), Some(&ConnectionKind::Soft)); + assert!(!connection_data.stream_normal_priorities.contains_key(&78)); +} + +#[tokio::test] +async fn test_repeated_preserve_for_same_adaptive_session_keeps_single_current_expiry_index() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr_a: SocketAddr = "127.0.0.1:55071".parse().unwrap(); + let addr_b: SocketAddr = "127.0.0.1:55072".parse().unwrap(); + let fp_a = Fingerprint::new("fp-key-a".to_string(), "127.0.0.1".to_string(), addr_a); + let fp_b = Fingerprint::new("fp-key-b".to_string(), "127.0.0.1".to_string(), addr_b); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr_a).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-reuse", + virtual_id: 7001, + provider: "provider-a", + stream_url: "http://localhost/live-a.m3u8", + addr: &addr_a, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 88, + meter_uid: 188, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fp_a, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(7001) }, + user_agent: Cow::Borrowed("ua-a"), + session_token: Some("tok-reuse"), + }) + .await; + let released = manager.release_connection(&addr_a).await; + assert!(released.addr_removed); + + manager.add_connection(&addr_b).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 99, + meter_uid: 199, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fp_b, + provider: "provider-b".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(7002) }, + user_agent: Cow::Borrowed("ua-b"), + session_token: Some("tok-reuse"), + }) + .await; + let released = manager.release_connection(&addr_b).await; + assert!(released.addr_removed); + + let expiry_index = manager.adaptive_expiry_index.lock().await; + assert_eq!(expiry_index.len(), 1); + assert!(expiry_index.contains_key(&AdaptiveExpiryKey { + username: String::from("user1"), + session_token: String::from("tok-reuse"), + uid: 88, + })); +} + +#[tokio::test] +async fn test_release_stream_preserved_path_emits_connection_update_event() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + let mut events = event_manager.get_event_channel(); + + let addr: SocketAddr = "127.0.0.1:55081".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-8".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-event", + virtual_id: 8001, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 111, + meter_uid: 211, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8001) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-event"), + }) + .await; + let _ = events.try_recv(); + + let released = manager.release_stream(&addr).await; + assert!(released.is_none(), "adaptive stream should remain logically preserved"); + + let event = events.try_recv().expect("preserved release should emit an ActiveUser event"); + assert!(matches!(event, EventMessage::ActiveUser(_))); +} + +#[tokio::test] +async fn test_release_stream_without_session_removes_adaptive_stream_instead_of_preserving() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55082".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-9".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 122, + meter_uid: 222, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8002) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("missing-session"), + }) + .await; + + let released = manager.release_stream(&addr).await; + assert!(released.is_some(), "stream without schedulable expiry must be removed"); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn test_due_adaptive_expiry_reschedules_when_session_timestamp_changes() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55083".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-10".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-reschedule", + virtual_id: 8003, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 133, + meter_uid: 233, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8003) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-reschedule"), + }) + .await; + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + + let key = + AdaptiveExpiryKey { username: String::from("user1"), session_token: String::from("tok-reschedule"), uid: 133 }; + let old_expires_at = { + let expiry_index = manager.adaptive_expiry_index.lock().await; + *expiry_index.get(&key).unwrap() + }; + + { + let mut connections = manager.connections.write().await; + let session = connections + .by_key + .get_mut("user1") + .unwrap() + .sessions + .iter_mut() + .find(|session| session.token == "tok-reschedule") + .unwrap(); + session.ts = session.ts.saturating_add(30); + } + + manager.process_due_adaptive_expiry_entries(old_expires_at).await; + + let new_expires_at = { + let expiry_index = manager.adaptive_expiry_index.lock().await; + *expiry_index.get(&key).unwrap() + }; + assert!(new_expires_at > old_expires_at); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn test_due_adaptive_expiry_removes_stale_index_when_preserved_stream_missing() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-11a".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-stale", + virtual_id: 8004, + provider: "provider-a", + stream_url: "http://localhost/stale.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 134, + meter_uid: 234, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8004) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-stale"), + }) + .await; + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + + let key = AdaptiveExpiryKey { username: String::from("user1"), session_token: String::from("tok-stale"), uid: 134 }; + let old_expires_at = { + let expiry_index = manager.adaptive_expiry_index.lock().await; + *expiry_index.get(&key).unwrap() + }; + + { + let mut connections = manager.connections.write().await; + let connection_data = connections.by_key.get_mut("user1").unwrap(); + connection_data.streams.clear(); + } + + manager.process_due_adaptive_expiry_entries(old_expires_at).await; + + let expiry_index = manager.adaptive_expiry_index.lock().await; + assert!(!expiry_index.contains_key(&key)); +} + +#[tokio::test] +async fn test_due_adaptive_expiry_does_not_block_on_full_cleanup_channel() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55084".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-11".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-full-channel", + virtual_id: 8004, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 144, + meter_uid: 244, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8004) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-full-channel"), + }) + .await; + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + + { + let mut connections = manager.connections.write().await; + let session = connections + .by_key + .get_mut("user1") + .unwrap() + .sessions + .iter_mut() + .find(|session| session.token == "tok-full-channel") + .unwrap(); + session.ts = session.ts.saturating_sub(default_hls_session_ttl_secs() + 1); + } + + let (cleanup_tx, mut cleanup_rx) = mpsc::channel(1); + cleanup_tx.send(CleanupEvent::ReleaseConnection { addr }).await.expect("prefill cleanup channel"); + manager.set_cleanup_sender(cleanup_tx); + + let process_result = tokio::time::timeout( + Duration::from_millis(100), + manager.process_due_adaptive_expiry_entries( + current_time_secs().saturating_add(default_hls_session_ttl_secs() + 1), + ), + ) + .await; + + assert!(process_result.is_ok(), "adaptive expiry processing must not await while holding locks"); + + let queued_event = cleanup_rx.try_recv().expect("prefilled cleanup event should remain queued"); + assert!(matches!(queued_event, CleanupEvent::ReleaseConnection { .. })); + assert!(manager.active_streams().await.is_empty()); +} + +#[tokio::test] +async fn test_preserved_adaptive_stream_reconnect_across_day_sets_previous_session_id() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55085".parse().unwrap(); + let next_addr: SocketAddr = "127.0.0.1:55086".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-rollover-a".to_string(), "127.0.0.1".to_string(), addr); + let next_fingerprint = Fingerprint::new("fp-rollover-b".to_string(), "127.0.0.1".to_string(), next_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-rollover", + virtual_id: 8005, + 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 first = manager + .update_connection(ActiveUserConnectionParams { + uid: 145, + meter_uid: 245, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveHls, ..test_channel(8005) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-rollover"), + }) + .await + .expect("initial adaptive session should register"); + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + + let forced_old_ts = { + let mut connections = manager.connections.write().await; + let stream = connections + .by_key + .get_mut("user1") + .unwrap() + .streams + .iter_mut() + .find(|stream| stream.session_token.as_deref() == Some("tok-rollover")) + .unwrap(); + stream.ts = stream.ts.saturating_sub(86_400); + stream.ts + }; + + manager.add_connection(&next_addr).await; + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 146, + meter_uid: 246, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &next_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::LiveDash, ..test_channel(8005) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-rollover"), + }) + .await + .expect("adaptive session should reconnect"); + + assert_eq!(second.previous_session_id, Some((forced_old_ts << 32) | u64::from(first.uid))); + assert!(second.ts > forced_old_ts); + assert_eq!(utc_day_from_secs(second.ts), utc_day_from_secs(current_time_secs())); +} + +#[tokio::test] +async fn stale_anonymous_socket_registration_is_pruned_by_gc() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let stale_addr: SocketAddr = "127.0.0.1:55011".parse().unwrap(); + let fresh_addr: SocketAddr = "127.0.0.1:55012".parse().unwrap(); + + manager.add_connection(&stale_addr).await; + { + let mut connections = manager.connections.write().await; + let registration = connections.key_by_addr.get_mut(&stale_addr).expect("socket registration should exist"); + registration.ts = registration.ts.saturating_sub(ANON_SOCKET_TTL + 1); + } + + if let Some(gc_ts) = &manager.gc_ts { + gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); + } + + manager.add_connection(&fresh_addr).await; + + let connections = manager.connections.read().await; + assert!(!connections.key_by_addr.contains_key(&stale_addr)); + assert!(connections.key_by_addr.contains_key(&fresh_addr)); +} + +#[tokio::test] +async fn named_socket_registration_exposes_expiry_deadline() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let stale_addr: SocketAddr = "127.0.0.1:55021".parse().unwrap(); + let fresh_addr: SocketAddr = "127.0.0.1:55022".parse().unwrap(); + let stale_fp = Fingerprint::new("fp-stale".to_string(), "127.0.0.1".to_string(), stale_addr); + let fresh_fp = Fingerprint::new("fp-fresh".to_string(), "127.0.0.1".to_string(), fresh_addr); + let mut stale_user = ProxyUserCredentials::default(); + stale_user.username = "user1".to_string(); + stale_user.max_connections = 1; + let mut fresh_user = ProxyUserCredentials::default(); + fresh_user.username = "user2".to_string(); + fresh_user.max_connections = 1; + + manager.add_connection(&stale_addr).await; + manager.add_connection(&fresh_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &stale_user, + session_token: "tok-stale-deadline", + virtual_id: 9201, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &stale_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &fresh_user, + session_token: "tok-fresh-deadline", + virtual_id: 9202, + provider: "provider-b", + stream_url: "http://localhost/live.m3u8", + addr: &fresh_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 201, + meter_uid: 301, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &stale_fp, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9201), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-stale-deadline"), + }) + .await + .expect("stale stream should register"); + manager + .update_connection(ActiveUserConnectionParams { + uid: 202, + meter_uid: 302, + username: "user2", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fresh_fp, + provider: "provider-b".intern(), + stream_channel: &test_adaptive_channel(9202), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-fresh-deadline"), + }) + .await + .expect("fresh stream should register"); + + { + let mut connections = manager.connections.write().await; + let stale_registration = connections.key_by_addr.get_mut(&stale_addr).expect("stale registration should exist"); + stale_registration.ts = stale_registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 1); + } + + let stale_deadline = + manager.socket_expiry_deadline(&stale_addr).await.expect("stale named socket should have an expiry deadline"); + let fresh_deadline = + manager.socket_expiry_deadline(&fresh_addr).await.expect("fresh named socket should have an expiry deadline"); + assert!(stale_deadline < fresh_deadline); +} + +#[tokio::test] +async fn touch_http_activity_refreshes_session_and_registration_without_stream() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55024".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-http-touch", + virtual_id: 9302, + 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 previous_ts = { + let mut connections = manager.connections.write().await; + let previous_ts = { + let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); + registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); + registration.ts + }; + let connection_data = connections.by_key.get_mut("user1").expect("user should exist"); + connection_data.sessions[0].ts = + connection_data.sessions[0].ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); + previous_ts + }; + + manager.touch_http_activity("user1", "tok-http-touch", &addr).await; + + let connections = manager.connections.read().await; + let registration = connections.key_by_addr.get(&addr).expect("registration should still exist"); + let connection_data = connections.by_key.get("user1").expect("user should still exist"); + assert!(registration.ts > previous_ts); + assert!(connection_data.sessions[0].ts >= registration.ts); +} + +#[tokio::test] +async fn touch_http_activity_does_not_reset_stream_started_at_ts() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr1: SocketAddr = "127.0.0.1:55030".parse().unwrap(); + let addr2: SocketAddr = "127.0.0.1:55031".parse().unwrap(); + let fingerprint = Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), addr1); + let mut user = ProxyUserCredentials::default(); + user.username = "user-touch-ts".to_string(); + + manager.add_connection(&addr1).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-ts", + virtual_id: 7777, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr1, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Simulate first HLS segment: creates the stream entry with ts = now + manager + .update_connection(ActiveUserConnectionParams { + uid: 601, + meter_uid: 701, + username: "user-touch-ts", + max_connections: 0, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(7777), + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-hls-ts"), + }) + .await + .expect("stream should be created"); + + // Record the original stream start timestamp + let original_ts = { + let connections = manager.connections.read().await; + connections + .by_key + .get("user-touch-ts") + .and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts"))) + .map(|s| s.ts) + .expect("stream should exist") + }; + + // Simulate manifest re-fetch (touch_http_activity called with a new addr) + manager.touch_http_activity("user-touch-ts", "tok-hls-ts", &addr2).await; + + // stream.ts must NOT have been reset — it represents session start time shown as Duration + let connections = manager.connections.read().await; + let stream = connections + .by_key + .get("user-touch-ts") + .and_then(|data| data.streams.iter().find(|s| s.session_token.as_deref() == Some("tok-hls-ts"))) + .expect("stream should still exist"); + assert_eq!(stream.ts, original_ts, "touch_http_activity must not reset the stream start timestamp"); + // Lightweight manifest activity must not move the active stream socket. + assert_eq!(stream.addr, addr1, "touch_http_activity must not replace the active stream addr"); +} + +#[tokio::test] +async fn touch_http_activity_does_not_migrate_adaptive_stream_to_manifest_addr_on_close() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + let mut events = event_manager.get_event_channel(); + + let segment_addr: SocketAddr = "127.0.0.1:55032".parse().unwrap(); + let manifest_addr: SocketAddr = "127.0.0.1:55033".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-hls-segment".to_string(), "127.0.0.1".to_string(), segment_addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-hls-manifest-touch".to_string(); + user.max_connections = 1; + + manager.add_connection(&segment_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-manifest-touch", + virtual_id: 7788, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &segment_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 602, + meter_uid: 702, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(7788), + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-hls-manifest-touch"), + }) + .await + .expect("stream should be created"); + + manager.touch_http_activity(&user.username, "tok-hls-manifest-touch", &manifest_addr).await; + + let released = manager.release_connection(&segment_addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty(), "adaptive close should preserve without history removal"); + assert_eq!(manager.user_connections(&user.username).await, 0); + assert!( + manager.active_streams().await.is_empty(), + "preserved rows stay out of active_streams (use panel_streams for StatusCheck)" + ); + let panel = manager.panel_streams().await; + assert_eq!(panel.len(), 1, "preserved adaptive/catchup session rows stay in panel snapshots"); + assert!(panel[0].preserved); + assert_eq!(panel[0].session_token.as_deref(), Some("tok-hls-manifest-touch")); + + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user should remain for preserved session"); + let stream = data + .streams + .iter() + .find(|stream| stream.session_token.as_deref() == Some("tok-hls-manifest-touch")) + .expect("preserved stream should remain internally tracked"); + assert!(stream.preserved); + assert_eq!(stream.addr, segment_addr, "closed segment must not migrate to manifest addr"); + assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); + drop(connections); + + let mut saw_preserved_update = false; + while let Ok(event) = events.try_recv() { + if matches!(event, EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream)) if stream.addr == segment_addr && stream.preserved) + { + saw_preserved_update = true; + } + } + assert!(saw_preserved_update, "preserving a stream must notify the frontend so adaptive TTL cleanup can hide it"); +} + +#[tokio::test] +async fn clear_unbound_session_addr_prunes_manifest_addr_while_stream_is_active_elsewhere() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let segment_addr: SocketAddr = "127.0.0.1:55034".parse().unwrap(); + let manifest_addr: SocketAddr = "127.0.0.1:55035".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-hls-segment-2".to_string(), "127.0.0.1".to_string(), segment_addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-hls-manifest-clear".to_string(); + user.max_connections = 1; + + manager.add_connection(&segment_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-manifest-clear", + virtual_id: 7789, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &segment_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 603, + meter_uid: 703, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(7789), + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-hls-manifest-clear"), + }) + .await + .expect("stream should be created"); + + manager.add_connection(&manifest_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-manifest-clear", + virtual_id: 7789, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &manifest_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + manager.clear_unbound_session_addr(&user.username, "tok-hls-manifest-clear", &manifest_addr).await; + + let connections = manager.connections.read().await; + assert!(!connections.key_by_addr.contains_key(&manifest_addr)); + let data = connections.by_key.get(&user.username).expect("user should exist"); + assert_eq!(data.streams[0].addr, segment_addr); + assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); +} + +#[tokio::test] +async fn clear_unbound_session_addr_prunes_touch_only_manifest_addr() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let segment_addr: SocketAddr = "127.0.0.1:55036".parse().unwrap(); + let manifest_addr: SocketAddr = "127.0.0.1:55037".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-hls-segment-3".to_string(), "127.0.0.1".to_string(), segment_addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-hls-manifest-touch-clear".to_string(); + user.max_connections = 1; + + manager.add_connection(&segment_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-hls-manifest-touch-clear", + virtual_id: 7790, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &segment_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 604, + meter_uid: 704, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(7790), + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-hls-manifest-touch-clear"), + }) + .await + .expect("stream should be created"); + + manager.touch_http_activity(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr).await; + manager.clear_unbound_session_addr(&user.username, "tok-hls-manifest-touch-clear", &manifest_addr).await; + + let connections = manager.connections.read().await; + assert!(!connections.key_by_addr.contains_key(&manifest_addr)); + let data = connections.by_key.get(&user.username).expect("user should exist"); + assert_eq!(data.streams[0].addr, segment_addr); + assert_eq!(data.sessions[0].addr, segment_addr); + assert!(!data.sessions[0].active_addrs.contains(&manifest_addr)); +} + +#[tokio::test] +async fn socket_expiry_deadline_does_not_refresh_active_vod_streams_without_activity() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55040".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-vod".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-vod-expiry".to_string(); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-vod-expiry", + virtual_id: 8888, + provider: "provider-a", + stream_url: "http://localhost/movie.mkv", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let mut channel = test_channel(8888); + channel.item_type = PlaylistItemType::Video; + channel.cluster = XtreamCluster::Video; + channel.url = "http://localhost/movie.mkv".intern(); + + manager + .update_connection(ActiveUserConnectionParams { + uid: 602, + meter_uid: 702, + username: "user-vod-expiry", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: Cow::Borrowed("player/1.0"), + session_token: Some("tok-vod-expiry"), + }) + .await + .expect("vod stream should be created"); + + let previous_registration_ts = { + let mut connections = manager.connections.write().await; + let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); + registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); + registration.ts + }; + + let deadline = + manager.socket_expiry_deadline(&addr).await.expect("VOD streams should stay scheduled for expiry tracking"); + + let unchanged_registration_ts = { + let connections = manager.connections.read().await; + connections.key_by_addr.get(&addr).expect("registration should still exist").ts + }; + + assert_eq!(unchanged_registration_ts, previous_registration_ts); + assert_eq!( + deadline, + previous_registration_ts.saturating_add(manager.active_socket_ttl_secs()), + "deadline checks must not refresh VOD sockets without real body activity" + ); +} + +#[tokio::test] +async fn touch_socket_activity_refreshes_registration_without_resetting_stream_start() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55041".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-vod-touch".to_string(), "127.0.0.1".to_string(), addr); + let mut user = ProxyUserCredentials::default(); + user.username = "user-vod-touch".to_string(); + user.max_connections = 1; + + manager.add_connection(&addr).await; + let mut channel = test_channel(8889); + channel.item_type = PlaylistItemType::Video; + channel.cluster = XtreamCluster::Video; + channel.url = "http://localhost/movie-2.mkv".intern(); + + let stream = manager + .update_connection(ActiveUserConnectionParams { + uid: 603, + meter_uid: 703, + username: "user-vod-touch", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &channel, + user_agent: Cow::Borrowed("player/1.0"), + session_token: None, + }) + .await + .expect("vod stream should be created"); + + let stale_registration_ts = { + let mut connections = manager.connections.write().await; + let registration = connections.key_by_addr.get_mut(&addr).expect("registration should exist"); + registration.ts = registration.ts.saturating_sub(DEFAULT_ACTIVE_SOCKET_TTL_SECS + 5); + registration.ts + }; + + manager.touch_socket_activity(&addr).await; + + let (refreshed_registration_ts, stream_started_at) = { + let connections = manager.connections.read().await; + let registration_ts = connections.key_by_addr.get(&addr).expect("registration should still exist").ts; + let stream_started_at = connections + .by_key + .get("user-vod-touch") + .and_then(|data| data.streams.iter().find(|active| active.uid == stream.uid)) + .expect("stream should still exist") + .ts; + (registration_ts, stream_started_at) + }; + + assert!(refreshed_registration_ts > stale_registration_ts); + assert_eq!(stream_started_at, stream.ts, "body activity must not reset visible stream duration"); +} + +#[tokio::test] +async fn update_session_addr_prunes_previous_registration_for_socket_bound_session() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let old_addr: SocketAddr = "127.0.0.1:55121".parse().unwrap(); + let new_addr: SocketAddr = "127.0.0.1:55122".parse().unwrap(); + let old_fingerprint = Fingerprint::new("fp-old".to_string(), "127.0.0.1".to_string(), old_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&old_addr).await; + manager.add_connection(&new_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-move", + virtual_id: 9101, + provider: "provider-a", + stream_url: "http://localhost/live.ts", + addr: &old_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: true, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 301, + meter_uid: 401, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &old_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::Live, ..test_channel(9101) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-move"), + }) + .await + .expect("initial live stream should register"); + + manager.update_session_addr("user1", "tok-move", &new_addr).await; + + let connections = manager.connections.read().await; + assert!( + !connections.key_by_addr.contains_key(&old_addr), + "previous range-request socket registration should be pruned once the session moved" + ); + assert!(connections.key_by_addr.contains_key(&new_addr)); + + let connection_data = connections.by_key.get("user1").expect("user connection data"); + assert_eq!(connection_data.sessions.len(), 1); + assert_eq!(connection_data.sessions[0].addr, new_addr); + assert_eq!(connection_data.streams.len(), 1); + assert_eq!(connection_data.streams[0].addr, new_addr); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn vod_session_survives_overlapping_and_seek_sockets() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let base_addr: SocketAddr = "127.0.0.1:55131".parse().unwrap(); + let range_addr: SocketAddr = "127.0.0.1:55132".parse().unwrap(); + let seek_addr: SocketAddr = "127.0.0.1:55133".parse().unwrap(); + let base_fingerprint = Fingerprint::new("fp-vod-base".to_string(), "127.0.0.1".to_string(), base_addr); + let range_fingerprint = Fingerprint::new("fp-vod-range".to_string(), "127.0.0.1".to_string(), range_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user1"); + user.max_connections = 1; + + manager.add_connection(&base_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-vod", + virtual_id: 9102, + provider: "provider-a", + stream_url: "http://localhost/movie.mkv", + addr: &base_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 302, + meter_uid: 402, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &base_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::Video, ..test_channel(9102) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-vod"), + }) + .await + .expect("initial vod stream should register"); + + manager.add_connection(&range_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 303, + meter_uid: 403, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &range_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::Video, ..test_channel(9102) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-vod"), + }) + .await + .expect("overlapping range request should reuse the same vod session"); + + assert_eq!(manager.user_connections("user1").await, 1); + assert!(manager.release_stream(&range_addr).await.is_none()); + let released = manager.release_connection(&range_addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty()); + + { + let connections = manager.connections.read().await; + assert!(connections.key_by_addr.contains_key(&base_addr)); + let connection_data = connections.by_key.get("user1").expect("user connection data"); + assert_eq!(connection_data.sessions[0].addr, base_addr); + assert_eq!(connection_data.streams[0].addr, base_addr); + } + + manager.add_connection(&seek_addr).await; + manager.update_session_addr("user1", "tok-vod", &seek_addr).await; + + { + let connections = manager.connections.read().await; + assert!( + connections.key_by_addr.contains_key(&base_addr), + "existing vod socket must remain registered while the session spans multiple requests" + ); + assert!(connections.key_by_addr.contains_key(&seek_addr)); + + let connection_data = connections.by_key.get("user1").expect("user connection data"); + assert_eq!(connection_data.sessions[0].addr, seek_addr); + assert_eq!(connection_data.streams[0].addr, seek_addr); + } + + assert!(manager.release_stream(&seek_addr).await.is_none()); + let released = manager.release_connection(&seek_addr).await; + assert!(released.addr_removed); + assert!(released.removed_streams.is_empty()); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get("user1").expect("user connection data"); + assert_eq!(connection_data.sessions[0].addr, base_addr); + assert_eq!(connection_data.streams[0].addr, base_addr); +} + +#[tokio::test] +async fn catchup_release_connection_preserves_logical_stream_until_session_expires() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55141".parse().unwrap(); + let next_addr: SocketAddr = "127.0.0.1:55142".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-catchup-1".to_string(), "127.0.0.1".to_string(), addr); + let next_fingerprint = Fingerprint::new("fp-catchup-2".to_string(), "127.0.0.1".to_string(), next_addr); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-catchup"); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-catchup", + virtual_id: 9103, + provider: "provider-a", + stream_url: "http://localhost/archive.ts", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + let first = manager + .update_connection(ActiveUserConnectionParams { + uid: 304, + meter_uid: 404, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::Catchup, ..test_channel(9103) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-catchup"), + }) + .await + .expect("initial catchup stream should register"); + + let released = manager.release_connection(&addr).await; + assert!(released.addr_removed); + assert!( + released.removed_streams.is_empty(), + "catchup stream should remain logically active between range requests" + ); + + assert_eq!(manager.user_connections(&user.username).await, 0); + assert!(manager.active_streams().await.is_empty()); + + let connections = manager.connections.read().await; + let preserved_stream = connections + .by_key + .get(&user.username) + .and_then(|data| data.streams.iter().find(|stream| stream.uid == first.uid)) + .expect("preserved catchup stream should stay internally tracked"); + assert!(preserved_stream.preserved); + drop(connections); + + manager.add_connection(&next_addr).await; + let second = manager + .update_connection(ActiveUserConnectionParams { + uid: 305, + meter_uid: 405, + username: &user.username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &next_fingerprint, + provider: "provider-a".intern(), + stream_channel: &StreamChannel { item_type: PlaylistItemType::Catchup, ..test_channel(9103) }, + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-catchup"), + }) + .await + .expect("catchup stream should reconnect"); + + assert_eq!(second.uid, first.uid); + assert_eq!(second.started_at, first.started_at); + assert!(!second.preserved); +} + +#[tokio::test] +async fn gc_keeps_active_ts_streams_even_when_user_timestamp_is_stale() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55013".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key-ts".to_string(), "127.0.0.1".to_string(), addr); + + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 144, + meter_uid: 244, + username: "user1", + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(9001), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("ts stream should register"); + + { + let mut connections = manager.connections.write().await; + let connection_data = connections.by_key.get_mut("user1").expect("user entry should exist"); + connection_data.ts = connection_data.ts.saturating_sub(USER_CON_TTL + 1); + } + + if let Some(gc_ts) = &manager.gc_ts { + gc_ts.store(current_time_secs().saturating_sub(USER_GC_TTL + 1), Ordering::Release); + } + + manager.active_streams().await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get("user1").expect("active user entry must survive gc"); + assert_eq!(connection_data.connections, 1); + assert_eq!(connection_data.streams.len(), 1); +} + +#[tokio::test] +async fn session_activation_keeps_first_hls_slot_uncommitted_before_stream_registration() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-hls-reserve"); + user.max_connections = 1; + + let first_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap(); + let second_addr: SocketAddr = "127.0.0.1:55181".parse().unwrap(); + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-first", + virtual_id: 9201, + provider: "provider-a", + stream_url: "http://localhost/live-a.m3u8", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-second", + virtual_id: 9202, + provider: "provider-a", + stream_url: "http://localhost/live-b.m3u8", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let first_admission = + manager.connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-first").await; + let second_admission = manager + .connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-second") + .await; + + assert_eq!(first_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(first_admission.kind, Some(ConnectionKind::Normal)); + assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_eq!(connection_data.connections, 0); + assert_eq!(connection_data.counts.normal, 0); + assert_eq!(connection_data.streams.len(), 0); + assert!(connection_data + .sessions + .iter() + .find(|session| session.token == "tok-first") + .is_some_and(|session| !session.lifecycle.is_counted())); + assert!(connection_data + .sessions + .iter() + .find(|session| session.token == "tok-second") + .is_some_and(|session| !session.lifecycle.is_counted())); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn binding_reserved_sessions_keeps_hard_and_soft_counts_stable() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-hls-soft"); + user.max_connections = 1; + user.soft_connections = 1; + + let first_addr: SocketAddr = "127.0.0.1:55182".parse().unwrap(); + let second_addr: SocketAddr = "127.0.0.1:55183".parse().unwrap(); + let first_fingerprint = Fingerprint::new("fp-hls-1".to_string(), "127.0.0.1".to_string(), first_addr); + let second_fingerprint = Fingerprint::new("fp-hls-2".to_string(), "127.0.0.1".to_string(), second_addr); + + manager.add_connection(&first_addr).await; + manager.add_connection(&second_addr).await; + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-normal", + virtual_id: 9203, + provider: "provider-a", + stream_url: "http://localhost/live-normal.m3u8", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-soft", + virtual_id: 9204, + provider: "provider-a", + stream_url: "http://localhost/live-soft.m3u8", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let first_admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + "tok-normal", + ) + .await; + assert_eq!(first_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(first_admission.kind, Some(ConnectionKind::Normal)); + + manager + .update_connection(ActiveUserConnectionParams { + uid: 401, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &first_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9203), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-normal"), + }) + .await + .expect("reserved normal session should bind"); + + let second_admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + "tok-soft", + ) + .await; + assert_eq!(second_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(second_admission.kind, Some(ConnectionKind::Soft)); + + manager + .update_connection(ActiveUserConnectionParams { + uid: 402, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Soft, + priority: 0, + soft_priority: 0, + fingerprint: &second_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9204), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-soft"), + }) + .await + .expect("reserved soft session should bind"); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_eq!(connection_data.connections, 2); + assert_eq!(connection_data.counts.normal, 1); + assert_eq!(connection_data.counts.soft, 1); + assert_eq!(connection_data.streams.len(), 2); + assert_eq!( + connection_data.stream_kinds.get(&401), + Some(&ConnectionKind::Normal), + "binding a reserved normal session must not increment counts twice" + ); + assert_eq!( + connection_data.stream_kinds.get(&402), + Some(&ConnectionKind::Soft), + "binding a reserved soft session must keep the soft classification" + ); +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn origin_policy_refresh_promotes_counted_soft_session_when_hard_slot_is_available() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-hls-policy-refresh"); + user.max_connections = 1; + user.soft_connections = 1; + + let normal_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap(); + let soft_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap(); + let normal_fingerprint = Fingerprint::new("fp-hls-policy-1".to_string(), "127.0.0.1".to_string(), normal_addr); + let soft_fingerprint = Fingerprint::new("fp-hls-policy-2".to_string(), "127.0.0.1".to_string(), soft_addr); + + manager.add_connection(&normal_addr).await; + manager.add_connection(&soft_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-normal", + virtual_id: 9210, + provider: "provider-a", + stream_url: "http://localhost/live-normal.m3u8", + addr: &normal_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-soft", + virtual_id: 9211, + provider: "provider-a", + stream_url: "http://localhost/live-soft.m3u8", + addr: &soft_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let normal_admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + "tok-normal", + ) + .await; + assert_eq!(normal_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(normal_admission.kind, Some(ConnectionKind::Normal)); + manager + .update_connection(ActiveUserConnectionParams { + uid: 411, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &normal_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9210), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-normal"), + }) + .await + .expect("normal stream should bind"); + + let soft_admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + "tok-soft", + ) + .await; + assert_eq!(soft_admission.permission, UserConnectionPermission::Allowed); + assert_eq!(soft_admission.kind, Some(ConnectionKind::Soft)); + manager + .update_connection(ActiveUserConnectionParams { + uid: 412, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Soft, + priority: 0, + soft_priority: 0, + fingerprint: &soft_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9211), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-soft"), + }) + .await + .expect("soft stream should bind"); + + assert!(manager.release_session_streams_and_counted_reservation(&user.username, "tok-normal").await); + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_eq!(connection_data.connections, 1); + assert_eq!(connection_data.counts.normal, 0); + assert_eq!(connection_data.counts.soft, 1); + assert_eq!( + connection_data + .sessions + .iter() + .find(|session| session.token == "tok-soft") + .and_then(|session| session.connection_kind), + Some(ConnectionKind::Soft) + ); + } + + let refreshed_kind = manager + .refresh_session_connection_kind_for_origin_policy( + &user.username, + user.max_connections, + user.soft_connections, + "tok-soft", + ) + .await; + assert_eq!(refreshed_kind, Some(ConnectionKind::Normal)); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_eq!(connection_data.connections, 1); + assert_eq!(connection_data.counts.normal, 1); + assert_eq!(connection_data.counts.soft, 0); + assert_eq!( + connection_data + .sessions + .iter() + .find(|session| session.token == "tok-soft") + .and_then(|session| session.connection_kind), + Some(ConnectionKind::Normal) + ); + assert_eq!(connection_data.stream_kinds.get(&412), Some(&ConnectionKind::Normal)); +} + +#[tokio::test] +async fn origin_policy_refresh_returns_none_for_pending_grace_without_available_slot() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-pending-grace-origin-policy"); + user.max_connections = 1; + + let active_addr: SocketAddr = "127.0.0.1:55195".parse().unwrap(); + let pending_addr: SocketAddr = "127.0.0.1:55196".parse().unwrap(); + let active_fingerprint = Fingerprint::new("active".to_string(), "127.0.0.1".to_string(), active_addr); + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-active", + virtual_id: 9301, + provider: "provider-a", + stream_url: "http://localhost/live-active.m3u8", + addr: &active_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 9301, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &active_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(9301), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-active"), + }) + .await + .expect("active stream should bind the only normal slot"); + + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-pending", + virtual_id: 9302, + provider: "provider-a", + stream_url: "http://localhost/live-pending.m3u8", + addr: &pending_addr, + connection_permission: UserConnectionPermission::GracePeriod, + connection_kind: None, + socket_bound: false, + }) + .await; + manager + .mark_pending_provider( + &user.username, + "tok-pending", + PendingProviderReason::GraceHold, + current_time_secs() + 30, + ) + .await + .expect("pending session should be marked"); + + let refreshed_kind = manager + .refresh_session_connection_kind_for_origin_policy( + &user.username, + user.max_connections, + user.soft_connections, + "tok-pending", + ) + .await; + + assert_eq!(refreshed_kind, None); +} + +#[tokio::test] +async fn release_unbound_session_reservation_frees_reserved_slot() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-release-reservation"); + user.max_connections = 1; + + let addr: SocketAddr = "127.0.0.1:55184".parse().unwrap(); + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-release", + virtual_id: 9205, + provider: "provider-a", + stream_url: "http://localhost/live-release.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let admission = manager + .connection_admission_for_session_activation(&user.username, user.max_connections, 0, "tok-release") + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + + manager.release_unbound_session_reservation(&user.username, "tok-release", None, false).await; + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_eq!(connection_data.connections, 0); + assert_eq!(connection_data.counts.normal, 0); + assert_eq!(connection_data.streams.len(), 0); + assert!(connection_data + .sessions + .iter() + .find(|session| session.token == "tok-release") + .is_some_and(|session| !session.lifecycle.is_counted())); +} + +#[tokio::test] +async fn preserved_reactivation_admission_does_not_create_ownerless_counted_slot() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-activation", 1, 0); + let addr: SocketAddr = "127.0.0.1:55195".parse().unwrap(); + let session_token = "tok-preserved-activation"; + let stream_uid = 501; + + commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) + .await; + + let virtual_admission = + manager.connection_admission(&user.username, user.max_connections, user.soft_connections).await; + assert_eq!(virtual_admission.permission, UserConnectionPermission::Exhausted); + + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(admission.kind, Some(ConnectionKind::Normal)); + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); + assert_no_real_connection_slots(connection_data); +} + +#[tokio::test] +async fn preserved_reactivation_admission_then_kicked_release_removes_state_without_ghost_counter() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-eviction", 1, 0); + let addr: SocketAddr = "127.0.0.1:55201".parse().unwrap(); + let session_token = "tok-preserved-eviction"; + let stream_uid = 601; + + commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) + .await; + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); + assert_no_real_connection_slots(connection_data); + } + + let released = manager.release_connection_as_kicked(&addr).await; + assert!(released.addr_removed); + assert_eq!(released.removed_streams.len(), 1); + let removed_stream = released.removed_streams.first().expect("kicked release must remove the preserved stream"); + assert_eq!(removed_stream.uid, stream_uid); + assert!(removed_stream.preserved); + assert_eq!(removed_stream.session_token.as_deref(), Some(session_token)); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert!(connection_data.streams.iter().all(|stream| stream.uid != stream_uid)); + assert!(connection_data.sessions.iter().all(|session| session.token != session_token)); + assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); + assert_no_real_connection_slots(connection_data); + drop(connections); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); +} + +#[tokio::test] +async fn preserved_reactivation_admission_then_lease_idle_cleanup_leaves_counters_at_zero() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-idle-cleanup", 1, 0); + let addr: SocketAddr = "127.0.0.1:55202".parse().unwrap(); + let session_token = "tok-preserved-idle-cleanup"; + let stream_uid = 602; + + commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) + .await; + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); + assert_no_real_connection_slots(connection_data); + } + + // Shared-HLS lease-idle cleanup delegates to this manager operation. + let counter_changed = manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; + assert!(!counter_changed, "removing an uncounted preserved stream must not change real counters"); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert!(connection_data.streams.iter().all(|stream| stream.uid != stream_uid)); + assert!(connection_data + .sessions + .iter() + .find(|session| session.token == session_token) + .is_some_and(|session| session.lifecycle == PlaybackLifecycle::Preserved)); + assert!(!connection_data.stream_kinds.contains_key(&stream_uid)); + assert_no_real_connection_slots(connection_data); + drop(connections); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); +} + +#[tokio::test] +async fn repeated_preserved_reactivation_cleanup_does_not_accumulate_connections() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-repeat", 4, 0); + for (session_token, stream_uid, addr) in + [("tok-preserved-repeat-one", 603, "127.0.0.1:55203"), ("tok-preserved-repeat-two", 604, "127.0.0.1:55204")] + { + let addr = addr.parse().unwrap(); + commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) + .await; + + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + + let counter_changed = + manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; + assert!(!counter_changed, "cleanup must not release a slot that was never committed"); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_no_real_connection_slots(connection_data); + drop(connections); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + } +} + +#[tokio::test] +async fn dashboard_counts_only_real_slots_during_preserved_reactivation_admission() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-dashboard", 1, 0); + let addr: SocketAddr = "127.0.0.1:55205".parse().unwrap(); + let session_token = "tok-preserved-dashboard"; + let stream_uid = 605; + + commit_and_preserve_adaptive_session(&manager, &user, session_token, stream_uid, addr, ConnectionKind::Normal) + .await; + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); + + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, stream_uid); + assert_no_real_connection_slots(connection_data); +} + +#[tokio::test] +async fn preserved_soft_reactivation_and_cleanup_leave_normal_slot_unchanged() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let user = test_user_credentials("user-preserved-soft", 1, 1); + let normal_addr: SocketAddr = "127.0.0.1:55206".parse().unwrap(); + let normal_fingerprint = + Fingerprint::new("fp-preserved-soft-normal".to_string(), normal_addr.ip().to_string(), normal_addr); + let soft_addr: SocketAddr = "127.0.0.1:55207".parse().unwrap(); + let normal_stream_uid = 606; + let session_token = "tok-preserved-soft"; + let soft_stream_uid = 607; + + manager.add_connection(&normal_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: normal_stream_uid, + meter_uid: 0, + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + connection_kind: ConnectionKind::Normal, + priority: user.priority, + soft_priority: user.soft_priority, + fingerprint: &normal_fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_channel(normal_stream_uid), + user_agent: Cow::Borrowed("ua"), + session_token: None, + }) + .await + .expect("normal stream should bind"); + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_single_normal_stream_slot(connection_data, normal_stream_uid); + } + + commit_and_preserve_adaptive_session( + &manager, + &user, + session_token, + soft_stream_uid, + soft_addr, + ConnectionKind::Soft, + ) + .await; + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, soft_stream_uid); + assert_single_normal_stream_slot(connection_data, normal_stream_uid); + } + + let admission = manager + .connection_admission_for_session_activation( + &user.username, + user.max_connections, + user.soft_connections, + session_token, + ) + .await; + assert_eq!(admission.permission, UserConnectionPermission::Allowed); + assert_eq!(admission.kind, Some(ConnectionKind::Soft)); + + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert_preserved_session_is_uncounted(connection_data, session_token, soft_stream_uid); + assert_single_normal_stream_slot(connection_data, normal_stream_uid); + } + + let counter_changed = manager.release_session_streams_and_counted_reservation(&user.username, session_token).await; + assert!(!counter_changed, "preserved soft cleanup must not release an uncommitted slot"); + { + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert!(connection_data.streams.iter().all(|stream| stream.uid != soft_stream_uid)); + assert!(!connection_data.stream_kinds.contains_key(&soft_stream_uid)); + assert!(connection_data + .sessions + .iter() + .find(|session| session.token == session_token) + .is_some_and(|session| session.lifecycle == PlaybackLifecycle::Preserved)); + assert_single_normal_stream_slot(connection_data, normal_stream_uid); + } + + manager.release_stream_by_uid(&normal_addr, normal_stream_uid).await.expect("normal stream should release"); + let connections = manager.connections.read().await; + let connection_data = connections.by_key.get(&user.username).expect("user connection data"); + assert!(connection_data.streams.is_empty()); + assert!(connection_data.stream_kinds.is_empty()); + assert_no_real_connection_slots(connection_data); + drop(connections); + assert_eq!(manager.active_users_and_connections().await, (0, 0)); +} + +#[tokio::test] +async fn release_unbound_session_reservation_ignores_stale_transition_version() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-stale-release"); + + let addr: SocketAddr = "127.0.0.1:55194".parse().unwrap(); + let stale_version = manager + .ensure_user_session_placeholder(CreateUserSessionParams { + user: &user, + session_token: "tok-stale-release", + virtual_id: 9206, + provider: "provider-a", + stream_url: "http://localhost/live-stale.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + let _ = manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-stale-release", + virtual_id: 9206, + provider: "provider-b", + stream_url: "http://localhost/live-stale-updated.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + manager.release_unbound_session_reservation(&user.username, "tok-stale-release", Some(stale_version), true).await; + + let session = manager + .get_and_update_user_session(&user.username, "tok-stale-release") + .await + .expect("stale rollback must not remove the newer session"); + assert!(session.transition_version > stale_version); + assert_eq!(session.provider.as_ref(), "provider-b"); + assert_eq!(session.stream_url.as_ref(), "http://localhost/live-stale-updated.m3u8"); +} + +#[tokio::test] +async fn clear_unbound_session_addr_prunes_manifest_addr_without_stream() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let first_addr: SocketAddr = "127.0.0.1:55185".parse().unwrap(); + let second_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = String::from("user-clear-addr"); + user.max_connections = 1; + + manager.add_connection(&first_addr).await; + manager.add_connection(&second_addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-clear-addr", + virtual_id: 9206, + provider: "provider-a", + stream_url: "http://localhost/live-clear.m3u8", + addr: &first_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-clear-addr", + virtual_id: 9206, + provider: "provider-a", + stream_url: "http://localhost/live-clear.m3u8", + addr: &second_addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + manager.clear_unbound_session_addr(&user.username, "tok-clear-addr", &second_addr).await; + + let connections = manager.connections.read().await; + let session = connections + .by_key + .get(&user.username) + .and_then(|connection_data| connection_data.sessions.iter().find(|session| session.token == "tok-clear-addr")) + .expect("session should remain"); + assert_eq!(session.addr, first_addr); + assert_eq!(session.active_addrs, vec![first_addr]); +} + +#[tokio::test] +async fn get_eviction_candidates_keeps_preserved_streams_evictable() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55300".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-key".to_string(), "192.168.1.100".to_string(), addr); + let username = "user-eviction-addr"; + let mut user = ProxyUserCredentials::default(); + user.username = username.to_string(); + user.max_connections = 1; + user.soft_connections = 0; + + // Create session first (HLS type = preserved after release) + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-preserved-1", + virtual_id: 5001, + provider: "provider-a", + stream_url: "http://localhost/live.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + + // Create stream + register connection + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 1, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(5001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-preserved-1"), + }) + .await + .expect("first stream"); + assert_eq!(manager.user_connections(username).await, 1); + + // Release -> stream becomes preserved, session becomes uncounted + manager.release_stream(&addr).await; + assert_eq!(manager.user_connections(username).await, 0, "preserved stream should not count"); + + let candidates = manager.get_eviction_candidates(username, "192.168.1.100").await; + assert!( + candidates.iter().any(|candidate| candidate.addr == addr), + "preserved stream should remain a direct eviction candidate" + ); +} + +#[tokio::test] +async fn get_eviction_candidates_does_not_count_preserved_streams_in_addr_counts() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55801".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-preserved-no-count".to_string(), "10.0.0.5".to_string(), addr); + let username = "user-preserved-addr-count"; + let mut user = ProxyUserCredentials::default(); + user.username = username.to_string(); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-preserved-addr-count", + virtual_id: 7000, + provider: "provider-preserved", + stream_url: "http://localhost/preserved.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 7000, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-preserved".intern(), + stream_channel: &test_adaptive_channel(7000), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-preserved-addr-count"), + }) + .await + .expect("stream should be created"); + + // Release -> stream becomes preserved, session becomes uncounted + manager.release_stream(&addr).await; + + // Preserved streams do not consume a counted slot — user_connections should be 0 + assert_eq!( + manager.user_connections(username).await, + 0, + "preserved stream should not count toward active connections" + ); + + // But the preserved stream is still a valid eviction candidate (valid victim) + let candidates = manager.get_eviction_candidates(username, "10.0.0.5").await; + assert!(candidates.iter().any(|c| c.addr == addr), "preserved stream should be an eviction candidate"); +} + +#[tokio::test] +async fn connection_admission_treats_preserved_stream_as_reserved_capacity() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55305".parse().unwrap(); + let fingerprint = Fingerprint::new("fp-preserved-admission".to_string(), "192.168.1.100".to_string(), addr); + let username = "user-preserved-admission"; + let mut user = ProxyUserCredentials::default(); + user.username = username.to_string(); + user.max_connections = 1; + + manager.add_connection(&addr).await; + manager + .create_user_session(CreateUserSessionParams { + user: &user, + session_token: "tok-preserved-admission", + virtual_id: 6000, + provider: "provider-a", + stream_url: "http://localhost/live-preserved.m3u8", + addr: &addr, + connection_permission: UserConnectionPermission::Allowed, + connection_kind: Some(ConnectionKind::Normal), + socket_bound: false, + }) + .await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 6000, + meter_uid: 0, + username, + max_connections: user.max_connections, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &fingerprint, + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(6000), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-preserved-admission"), + }) + .await + .expect("preserved stream should be created"); + + manager.release_connection(&addr).await; + assert_eq!(manager.user_connections(username).await, 0, "preserved stream stays uncounted for active snapshots"); + + let admission = manager.connection_admission(username, user.max_connections, 0).await; + assert_eq!( + admission.permission, + UserConnectionPermission::Exhausted, + "a preserved stream should still reserve capacity against unrelated playback admissions" + ); +} + +#[tokio::test] +async fn connection_admission_for_session_evaluates_admission_for_uncounted_session() { + // Bug: connection_admission_for_session returns Allowed for any existing session, + // even if it's uncounted (preserved). This causes strategy evaluation to be skipped. + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let addr: SocketAddr = "127.0.0.1:55310".parse().unwrap(); + let username = "user-uncounted-admission"; + let mut user = ProxyUserCredentials::default(); + user.username = username.to_string(); + user.max_connections = 1; + user.soft_connections = 0; + + // Create session + counted stream (HLS type = preserved after release) + manager.add_connection(&addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 1, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &Fingerprint::new("fp".to_string(), "192.168.1.50".to_string(), addr), + provider: "provider-a".intern(), + stream_channel: &test_adaptive_channel(6001), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-uncounted"), + }) + .await + .expect("first stream"); + + // Release to preserve (uncounted session, but counts.normal still = 1 from the stream) + manager.release_stream(&addr).await; + // After preserve: session is uncounted, stream is preserved, connections=0 + // BUT the stream was removed, so counts.normal is decremented -> counts=0 + assert_eq!(manager.user_connections(username).await, 0); + + // Add a second stream first - this uses a different session token and consumes the slot + let second_addr: SocketAddr = "192.168.1.100:55311".parse().unwrap(); + manager.add_connection(&second_addr).await; + manager + .update_connection(ActiveUserConnectionParams { + uid: 2, + meter_uid: 0, + username, + max_connections: 1, + soft_connections: 0, + connection_kind: ConnectionKind::Normal, + priority: 0, + soft_priority: 0, + fingerprint: &Fingerprint::new("fp2".to_string(), "192.168.1.100".to_string(), second_addr), + provider: "provider-b".intern(), + stream_channel: &test_channel(6002), + user_agent: Cow::Borrowed("ua"), + session_token: Some("tok-second"), + }) + .await + .expect("second stream"); + // Now user is at limit: connections=1, counts.normal=1, max_connections=1 + assert_eq!(manager.user_connections(username).await, 1); + + // connection_admission_for_session for the PRESERVED session token should return + // Exhausted so that eviction strategies can run and evict the preserved stream, + // freeing a slot for the uncounted session to reactivate + let admission = manager.connection_admission_for_session(username, 1, 0, "tok-uncounted").await; + assert_eq!( + admission.permission, + UserConnectionPermission::Exhausted, + "uncounted session should not bypass admission when user is at limit; \ + bug: session exists -> Allowed -> strategy evaluation skipped" + ); +} + +#[tokio::test] +async fn playback_transition_gate_serializes_same_session() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + + let first_guard = manager.acquire_playback_transition("user-gated", "tok-gated").await; + let second_manager = Arc::clone(&manager); + let waiting = tokio::spawn(async move { + let _second_guard = second_manager.acquire_playback_transition("user-gated", "tok-gated").await; + }); + + tokio::time::sleep(Duration::from_millis(25)).await; + assert!( + !waiting.is_finished(), + "same-session transition gate should block a concurrent transition until the first completes" + ); + + drop(first_guard); + tokio::time::timeout(Duration::from_millis(100), waiting) + .await + .expect("second transition should proceed once the first guard is released") + .expect("second transition task should complete"); +} + +#[tokio::test] +async fn playback_transition_gate_cleanup_removes_idle_gates_on_next_acquire() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = ActiveUserManager::new(&config, &geoip, &event_manager); + + let first_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-first").await; + assert_eq!(manager.transition_gates.lock().await.len(), 1); + drop(first_guard); + + let second_guard = manager.acquire_playback_transition("user-gated-cleanup", "tok-second").await; + assert_eq!(manager.transition_gates.lock().await.len(), 1); + drop(second_guard); +} + +#[tokio::test] +async fn check_divergence_detects_connection_count_mismatch() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + + let addr: SocketAddr = "127.0.0.1:55902".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "div-user-2".to_string(); + + // Create a counted session without a stream or matching legacy counter. + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); + data.add_session(UserSession { + token: "tok-div-2".to_string(), + transition_version: 1, + 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], + ts: current_time_secs(), + started_at: current_time_secs(), + permission: UserConnectionPermission::Allowed, + connection_kind: None, + lifecycle: PlaybackLifecycle::Active, + }); + } + + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user connection data"); + let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); + assert!(snapshot.kinds.contains(&DivergenceKind::CountedSessionWithoutStream)); + assert!(snapshot.kinds.contains(&DivergenceKind::ConnectionCountMismatch { legacy: 0, counted: 1 })); + drop(connections); + manager.log_divergence_snapshot(Some(snapshot)).await; +} + +#[tokio::test] +async fn check_divergence_detects_stream_without_counted_session() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + + let addr: SocketAddr = "127.0.0.1:55903".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "div-user-3".to_string(); + + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); + + // Add a session with GraceHold pending — exempt from Invariant 1 + data.add_session(UserSession { + token: "tok-div-3".to_string(), + transition_version: 1, + 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], + ts: current_time_secs(), + started_at: current_time_secs(), + permission: UserConnectionPermission::Allowed, + connection_kind: None, + lifecycle: PlaybackLifecycle::PendingProvider { + data: PendingProviderState { + reason_code: PendingProviderReason::GraceHold, + created_at: current_time_secs(), + deadline: current_time_secs() + 30, + version: 1, + wake_source: None, + }, + }, + }); + data.increment_kind(ConnectionKind::Normal); + + // Add a stream whose session_token doesn't match any counted session + let orphan_stream = StreamInfo::new(shared::model::StreamInfoParams { + uid: 903, + meter_uid: 0, + username: &user.username, + addr: &addr, + client_ip: "127.0.0.1", + provider: "provider-a".intern(), + stream_channel: StreamChannel { + target_id: 1, + virtual_id: 9003, + provider_id: 1, + input_name: "provider-a".intern(), + item_type: PlaylistItemType::Live, + cluster: XtreamCluster::Live, + group: "g".intern(), + title: "t".intern(), + url: "http://localhost/stream.ts".intern(), + shared: false, + shared_joined_existing: None, + shared_stream_id: None, + technical: None, + epg_channel_id: None, + epg_reference_ts: None, + upstream_user_agent: None, + }, + user_agent: "ua".to_string(), + country_code: None, + session_token: Some("tok-orphan"), + }); + data.streams.push(orphan_stream); + data.stream_kinds.insert(903, ConnectionKind::Normal); + } + + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user connection data"); + let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); + assert!(snapshot.kinds.contains(&DivergenceKind::StreamWithoutCountedSession)); + drop(connections); + manager.log_divergence_snapshot(Some(snapshot)).await; +} + +#[tokio::test] +async fn divergence_log_rate_limited_within_cooldown_window() { + let config = Config::default(); + let geoip = Arc::new(ArcSwapOption::::default()); + let event_manager = Arc::new(EventManager::new()); + let manager = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); + + let addr: SocketAddr = "127.0.0.1:55904".parse().unwrap(); + let mut user = ProxyUserCredentials::default(); + user.username = "div-user-4".to_string(); + + // Create mismatch + { + let mut connections = manager.connections.write().await; + let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); + data.increment_kind(ConnectionKind::Normal); + data.add_session(UserSession { + token: "tok-div-4".to_string(), + transition_version: 1, + 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], + ts: current_time_secs(), + started_at: current_time_secs(), + permission: UserConnectionPermission::Allowed, + connection_kind: None, + lifecycle: PlaybackLifecycle::Prepared, + }); + } + + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user connection data"); + let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); + drop(connections); + manager.log_divergence_snapshot(Some(snapshot)).await; + let key = divergence_key(&user.username, &DivergenceKind::ConnectionCountMismatch { legacy: 1, counted: 0 }); + let first_logged = { + let cache = manager.divergence_cache.lock().await; + let entry = cache.peek(&key).expect("first divergence should populate the cache"); + assert_eq!(entry.count_since_last_log, 0); + entry.last_logged + }; + + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user connection data"); + let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); + drop(connections); + manager.log_divergence_snapshot(Some(snapshot)).await; + let connections = manager.connections.read().await; + let data = connections.by_key.get(&user.username).expect("user connection data"); + let snapshot = ActiveUserManager::build_divergence_snapshot(data, &user.username); + drop(connections); + manager.log_divergence_snapshot(Some(snapshot)).await; + let cache = manager.divergence_cache.lock().await; + let entry = cache.peek(&key).expect("repeated divergence should remain cached"); + assert_eq!(entry.count_since_last_log, 2); + assert_eq!(entry.last_logged, first_logged); +} diff --git a/backend/session/src/lib.rs b/backend/session/src/lib.rs index 7eb9f7808..299fc28ea 100644 --- a/backend/session/src/lib.rs +++ b/backend/session/src/lib.rs @@ -12,8 +12,8 @@ //! recursive at the value level - `ConnectionManager` holds an //! `Arc`, which holds an `Arc`, //! which holds a back-reference to the `SharedStreamManager`. Splitting them -//! would require callback traits that exist only to break the cycle, which the -//! modularization plan rules out. +//! would require callback traits solely to break the ownership cycle without +//! improving the runtime boundary. //! //! Nothing here names `AppState`: the runtime takes the handles it needs. diff --git a/backend/session/src/provider_lineup_manager.rs b/backend/session/src/provider_lineup_manager.rs index 1f2d13a4b..daba96e7d 100644 --- a/backend/session/src/provider_lineup_manager.rs +++ b/backend/session/src/provider_lineup_manager.rs @@ -404,7 +404,7 @@ impl MultiProviderLineup { /// } /// ``` async fn acquire(&self, with_grace: bool, grace_period_timeout_secs: u64) -> ProviderAllocation { - // Phase 1: prefer providers with available capacity (no grace allocations), + // Prefer providers with available capacity (no grace allocations), // scanning priority groups from highest -> lowest. for priority_group in &self.providers { let allocation = @@ -418,7 +418,7 @@ impl MultiProviderLineup { return ProviderAllocation::Exhausted; } - // Phase 2: all providers are at capacity, allow grace allocations (still respecting priority order). + // If every provider is at capacity, allow grace allocations while respecting priority order. for priority_group in &self.providers { let allocation = Self::acquire_next_provider_from_group(priority_group, true, grace_period_timeout_secs).await; @@ -432,7 +432,7 @@ impl MultiProviderLineup { // it intended to use with redirects to cycle through provider async fn get_next(&self, grace_period_timeout_secs: u64) -> Option> { - // Phase 1: prefer providers with available capacity (no grace allocations), + // Prefer providers with available capacity (no grace allocations), // scanning priority groups from highest -> lowest. for priority_group in &self.providers { if let Some(config) = @@ -442,7 +442,7 @@ impl MultiProviderLineup { } } - // Phase 2: no provider is available, allow grace. + // If no provider is available, allow grace. for priority_group in &self.providers { if let Some(config) = Self::get_next_provider_from_group(priority_group, true, grace_period_timeout_secs).await @@ -630,7 +630,7 @@ impl ProviderLineupManager { } pub async fn reconcile_connections(&self, mut counts: HashMap, usize>) { - // 1. Synchronize known providers from actual counts. + // Synchronize known providers from actual counts. // We take a snapshot of the keys and locks to avoid holding the DashMap's internal // shard locks while awaiting the RwLock of each provider. Holding both can lead to deadlocks. let snapshot: Vec<_> = @@ -646,8 +646,8 @@ impl ProviderLineupManager { } } - // 2. Handle new providers that weren't in the registry yet (e.g. newly added/renamed). - // Same lock-ordering rule as Phase 1: clone the Arc> out of DashMap first, + // Handle providers that are not in the registry yet, such as newly added or renamed providers. + // Preserve the lock order above: clone the Arc> out of DashMap first, // then await on the provider RwLock without holding any DashMap shard lock. for (name, count) in counts { let conn_lock = self diff --git a/backend/session/src/streams/shared_stream_manager.rs b/backend/session/src/streams/shared_stream_manager.rs index 04d35be89..c3c20bab7 100644 --- a/backend/session/src/streams/shared_stream_manager.rs +++ b/backend/session/src/streams/shared_stream_manager.rs @@ -681,9 +681,8 @@ pub struct SharedStreamManager { /// The four state handles the shared-stream paths need. /// /// These functions used to take the whole `AppState` and reach into four of its -/// fields. Naming the server state is what the plan forbids for anything that -/// might leave `api`, so the slice is explicit and the composition root builds -/// it. +/// fields. Keeping this slice explicit avoids coupling the session crate to the +/// API server state; the composition root supplies the required handles. #[derive(Clone, Copy)] pub struct SharedStreamCtx<'a> { pub app_config: &'a Arc, diff --git a/bin/check-workspace-deps.sh b/bin/check-workspace-deps.sh index 33e4a4897..6b05cf3cb 100755 --- a/bin/check-workspace-deps.sh +++ b/bin/check-workspace-deps.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # -# Architecture gate for the modularization plan. +# Architecture gate for workspace dependencies. # # Cargo already rejects dependency cycles, so this script does not look for them. -# What it enforces is the stricter rule the plan asks for: every edge between two -# workspace packages must be listed here explicitly, with its dependency kind, so +# It requires every edge between two workspace packages to be listed explicitly +# with its dependency kind, so # that adding one - or promoting a dev-only edge to a build-time one - is a # deliberate, reviewed act rather than a side effect of an `use` statement. # @@ -13,8 +13,7 @@ # * an edge listed here that no longer exists fails it too, so the list cannot # rot into a record of dependencies the workspace has since dropped. # -# Extend the allowlist below when a phase introduces a new package edge, in the -# same change that introduces it. +# Extend the allowlist in the same change that introduces a package edge. # # Usage: # check-workspace-deps.sh diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index e6641ee61..14d85befa 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -26,6 +26,7 @@ - [api-proxy.yml (Server, Users & RBAC)](./configuration/api-proxy.md) - [template.yml (Macros & Regex)](./configuration/template.md) - [mapping.yml (Mapper DSL & Logic)](./configuration/mapping-dsl.md) + - [Secrets & Environment Variables](./configuration/secrets.md) - [Operations & Debugging (CLI & DB Dumps)](./operations-debugging.md) - [DVR Operator Reference](./operator/dvr.md) - [Examples,Recipes & Ecosystem Stacks](./examples-recipes.md) diff --git a/docs/src/configuration/secrets.md b/docs/src/configuration/secrets.md new file mode 100644 index 000000000..660fe8ca1 --- /dev/null +++ b/docs/src/configuration/secrets.md @@ -0,0 +1,148 @@ +# 🔐 Secrets & Environment Variables + +Every deployment needs credentials: provider logins, output-user passwords, webhook tokens, web server secrets. +Tuliprox keeps the repository free of real credentials while making it easy to inject them at runtime. + +> **Host-agnostic.** These instructions deliberately do not assume any particular host, container platform, or +> cloud provider. Secrets are read from the process environment and from files we generate on the machine itself, +> so the same workflow works whether you run a container, a service manager, a process supervisor, a small server, +> or a hosted secret store of your choice. The mechanism to supply an environment variable is always the same: +> it must be present in the environment of the `tuliprox` process at startup. + +## What counts as a secret + +| File | Field | Example env var | +|:------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------| +| `source.yml` | input `url`, `username`, `password` | `CLOUDTV_1_URL`, `CLOUDTV_1_USER`, `CLOUDTV_1_PASS` | +| `source.yml` | input `epg.sources[].url` | `CLOUDTV_1_EPG_URL` | +| `source.yml` | input `panel_api.api_key` (account management) | `PROVIDER_PANEL_API_KEY` | +| `api-proxy.yml` | output user `password` / `token` for every published user | `XTR_USER_LOCAL_PASS`, `XTR_USER_LOCAL_TOKEN` | +| `config.yml` | `web_ui.auth.secret` (JWT signing, 64-hex) | `TULIPROX_WEB_SECRET` | +| `config.yml` | messaging webhooks/tokens: Telegram bot token, Discord URL, Pushover token/key, ntfy/Gotify tokens, Slack URL, generic REST URL + `signing_secret` + `Authorization` headers | `TULIPROX_DISCORD_WEBHOOK`, `TULIPROX_TELEGRAM_TOKEN`, `TULIPROX_SIGNING_SECRET` | +| `config.yml` | `metadata_update.tmdb.api_key` | `TULIPROX_TMDB_API_KEY` | +| `config.yml` | `reverse_proxy.rewrite_secret` | `TULIPROX_PROXY_REWRITE_SECRET` | +| `config/user.txt` | Web UI Argon2 password hashes (see below — not env-injectable) | *file only* | + +Anything a provider, a player, a notification bot, or a browser authenticates with is a secret and must not be in git. + +## `${env:VAR}` interpolation + +Every config file that Tuliprox reads (`config.yml`, `source.yml`, `api-proxy.yml`, `mapping.yml`, `template.yml`) +supports environment-variable interpolation with the syntax: + +```text +${env:VAR_NAME} +``` + +Variable names match `[a-zA-Z_][a-zA-Z0-9_]*`. + +- **How it works:** the placeholder is resolved *before* the file is parsed as YAML, from the environment of the + running process. If the variable is missing, Tuliprox logs + `Could not resolve env var 'VAR_NAME'` and leaves the literal `${env:VAR_NAME}` in place so the problem is visible. +- **Quoting:** keep string values quoted, exactly like the value the variable will expand to: + + ```yaml + inputs: + - name: provider + type: xtream + url: "${env:CLOUDTV_URL}" + username: "${env:CLOUDTV_USER}" + password: "${env:CLOUDTV_PASS}" + ``` + + This stays valid YAML before and after substitution. +- **Use only for string fields.** Numbers (`exp_date`, ports, `token_ttl_mins`) and booleans (`enabled`) still belong + directly in the file; injecting them through env vars is fragile. +- **Not supported in `user.txt`.** Web UI credential files are read directly and must be written on the machine + (see below). +- **Also works in CLI paths:** any `--home` / `-c` / `-i` / `-a` argument can be `${env:...}` too. + +### Supplying the variables + +Because Tuliprox reads from the process environment, any mechanism that sets environment variables works: + +- `export TULIPROX_WEB_SECRET=...` in the shell / init script that starts the process, +- `environment:` entries in a container or compose file, +- `Environment=` lines of a service unit, +- the secret store / environment settings of whatever runtime you picked. + +> Tuliprox does **not** read a `.env` file automatically. If your runtime auto-loads `.env` files, that is fine — the +> process environment is what matters. For a quick manual check on a Linux shell: +> `VAR=value ./tuliprox -s` or `export VAR=value && ./tuliprox -s`. + +## Web UI credentials (`user.txt`) + +Tuliprox stores password *hashes*, never plain text. Each line is `username:argon2_hash[:group1,group2]`; without +groups the user defaults to `admin`. + +1. Generate a hash with the interactive CLI prompt (it needs a real terminal and cannot read from stdin): + + ```bash + tuliprox --genpwd + ``` + +2. Write the line into `config/user.txt` (or the user file configured in `config.yml`): + + ```text + myuser:$argon2id$v=19$m=19456,t=2,p=1$... + ``` + +3. Restart the server (or reload) and verify the login. + +`user.txt` is environment-injectable only in the sense that the *path* may come from a CLI/`${env:...}` path; the +hashes themselves are generated on the machine and are never committed. The `config/user.txt` shipped in this +repository contains sample hashes for the demo accounts `test` / `nobody` documented in `config/README.md` — replace +them before going live. + +## JWT secret (`web_ui.auth.secret`) + +If `web_ui.auth.secret` is omitted, Tuliprox generates one in-memory and **all active logins are invalidated on every +restart**. For production, pin a static 64-character hexadecimal string and keep it stable: + +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Put the result in an environment variable and reference it from `config.yml`: + +```yaml +web_ui: + auth: + secret: "${env:TULIPROX_WEB_SECRET}" +``` + +Rotating it invalidates all sessions — do it deliberately, not on a whim. + +## Pre-publish checklist + +Run these before pushing anything to a public repository: + +```bash +# Anything obviously credential-like in tracked configs +git grep -n -i -E "(password|secret|token|api_key)[[:space:]]*:" -- config/ + +# Explicit values that should have been placeholders or generated on-site +git grep -n -i -E "your_|TODO|changeme|example|\.secret|localsecret" -- config/ + +# Staged files (never a secret should be here) +git diff --cached --stat +git status --porcelain +``` + +Other good habits: + +- Keep `logging.sanitize_sensitive_info: true` (default). It masks passwords, provider URLs and client IPs in logs + so shared logs can't leak credentials. +- Keep `runtime_config_report_enabled: false` unless you need a startup dump; when enabled it masks sensitive values + as `***` anyway. +- Never commit `data/`, `target/`, `downloads/`, `cache/`, `backup/`, `.env`, or any runtime directory — the + repository `.gitignore` already covers the common ones. +- If a secret ever lands in history, rotate it (it is compromised history, not just a file) and rewrite history + with `git filter-repo` rather than committing new secrets on top. + +## Related + +- [`config.yml` core configuration](config.md) — `web_auth`, messaging, `metadata_update.tmdb` +- [`source.yml` inputs & providers](source.md) — provider credentials, EPG, backup URLs +- [`api-proxy.yml` published users](api-proxy.md) — output user credentials and tokens +- [Getting Started](../getting-started.md) — where config files live and how the home directory is resolved diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 2c90011d2..8ae589b5d 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -9,6 +9,7 @@ 5. [Streaming & Proxy](configuration/reverse-proxy.md) 6. [Templates](configuration/template.md) 7. [Mappings](configuration/mapping-dsl.md) +8. [Secrets & Environment Variables](configuration/secrets.md) — inject provider and user credentials safely ## Run Tuliprox via docker compose diff --git a/frontend/src/app/components/playlist/target_table.rs b/frontend/src/app/components/playlist/target_table.rs index b6509f340..f770cc58f 100644 --- a/frontend/src/app/components/playlist/target_table.rs +++ b/frontend/src/app/components/playlist/target_table.rs @@ -165,7 +165,6 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { Callback::from(move |(name, _): (String, _)| { if let Ok(action) = TargetTableAction::from_str(&name) { match action { - TargetTableAction::Edit => {} TargetTableAction::Refresh => { let translate = translate.clone(); let services_ctx = services_ctx.clone(); @@ -220,7 +219,6 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { <> definition={definition.clone()} /> -


@@ -238,7 +236,6 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { #[derive(Debug, Clone, Eq, PartialEq, strum_macros::Display, strum_macros::EnumString)] #[strum(serialize_all = "snake_case")] enum TargetTableAction { - Edit, Refresh, Delete, } diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs index e5f0cb934..09466f3fb 100644 --- a/frontend/src/lib.rs +++ b/frontend/src/lib.rs @@ -1,14 +1,9 @@ // Shared clippy policy: see [workspace.lints.clippy] in the root Cargo.toml. // -// `clippy::all` + `clippy::pedantic` reach this crate for the first time in the -// modularization plan's Phase 0. Everything with a machine-applicable fix has -// already been applied via `cargo clippy --fix`. What remains needs hand edits -// inside Yew `html!` macro bodies and view state, which Phase 0 must not touch: -// it is a behaviour-preserving lint-policy change, not a frontend rewrite. -// -// This crate is out of scope for the modularization plan (see "The frontend is a -// separate plan"). The list is tracked debt for that plan, not workspace policy — -// the backend is held to the full policy without any such list. +// Machine-applicable fixes have already been applied. The remaining allowances +// cover established Yew view and callback patterns that require deliberate UI +// changes rather than mechanical rewrites. They are local to this crate; other +// workspace crates inherit the strict policy without this list. #![allow(clippy::cast_precision_loss)] // integer -> f64 for chart/layout maths #![allow(clippy::cast_possible_truncation)] // f64 -> integer pixel coordinates #![allow(clippy::cast_sign_loss)] // clamped non-negative pixel/scroll values diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 5fc2c83b5..5deef7252 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,15 +1,9 @@ // Shared clippy policy: see [workspace.lints.clippy] in the root Cargo.toml. // -// `clippy::all` + `clippy::pedantic` are switched on workspace-wide by the -// modularization plan's Phase 0. Everything clippy can rewrite mechanically has -// been applied with `cargo clippy --fix`. The lints below are the residue that -// has no machine-applicable fix and would need hand edits to DTO, serde and -// parsing code that Phase 0 is explicitly not allowed to change the behaviour of. -// -// They are crate-local debt, not workspace policy: the backend crate — the one -// this plan actually modularizes — is held to the full policy with no such list, -// and any crate extracted from it inherits the strict policy rather than this one. -// Burn these down in their own change batches, never inside an extraction. +// Machine-applicable fixes have already been applied. The remaining allowances +// document established DTO, serde, and parser APIs that need deliberate changes +// rather than mechanical rewrites. They are local to this crate; other workspace +// crates inherit the strict policy without this list. #![allow(clippy::cast_possible_truncation)] // bounded lengths/ids in wire DTOs #![allow(clippy::cast_possible_wrap)] // bounded lengths/ids in wire DTOs #![allow(clippy::cast_precision_loss)] // integer -> f64 for display/ratio maths diff --git a/shared/src/model/auth/user.rs b/shared/src/model/auth/user.rs index 69d069d38..5e66f8717 100644 --- a/shared/src/model/auth/user.rs +++ b/shared/src/model/auth/user.rs @@ -28,9 +28,8 @@ pub struct Claims { pub permissions: PermissionSet, #[serde(default)] pub pwd_version: u32, - /// Stable subject identifier for the principal. `None` for - /// pre-Phase 2 tokens; tokens missing this field are rejected by - /// the validators. Built-in admin tokens carry the reserved + /// Stable subject identifier for the principal. Legacy tokens may omit + /// this field and are rejected by the validators. Built-in admin tokens carry the reserved /// `builtin:admin` subject; web/API tokens carry the /// registry-allocated `UserId`. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/shared/src/model/identity_registry.rs b/shared/src/model/identity_registry.rs index 31974ef17..c19c002f0 100644 --- a/shared/src/model/identity_registry.rs +++ b/shared/src/model/identity_registry.rs @@ -1,9 +1,7 @@ //! Identity registry types for stable subject identities. //! -//! Phase 0 + Phase 1 introduce the [`UserId`] newtype used by recording -//! metadata, scope strings, and the per-user quota config map. The full -//! registry (with on-disk mapping, bootstrap, and rename migration) is added -//! in Phase 2 (Task 11). +//! [`UserId`] provides the stable identity used by recording metadata, scope +//! strings, and per-user quota configuration. use std::fmt; diff --git a/shared/src/model/recording.rs b/shared/src/model/recording.rs index 3b221fa8f..1e664038d 100644 --- a/shared/src/model/recording.rs +++ b/shared/src/model/recording.rs @@ -1,8 +1,7 @@ //! DVR recording domain types. //! -//! See `docs/superpowers/specs/2026-07-23-extended-dvr-design.md` and the -//! matching plan for the full design. This module is the source of truth for -//! `RecordingMetadata`; persistence, runtime, and DTO layers all mirror it. +//! This module is the source of truth for `RecordingMetadata`; persistence, +//! runtime, and DTO layers all mirror it. use super::identity_registry::UserId; use std::fmt; diff --git a/shared/src/model/stream_probe_failure.rs b/shared/src/model/stream_probe_failure.rs index ec21a8a00..5c9282f4e 100644 --- a/shared/src/model/stream_probe_failure.rs +++ b/shared/src/model/stream_probe_failure.rs @@ -1,10 +1,7 @@ //! A stream probe that did not come back with metadata. //! -//! `ffprobe` outcomes were written to the item store and logged at `warn`, -//! and that was the whole audience: an operator learned that a provider had -//! gone dark by noticing it themselves. This is the event that closes that -//! gap, and it is the one the plugin plan wants for "alert me when a stream -//! dies" — see `plugin-system-plan.md` §7. +//! Failed `ffprobe` outcomes are persisted and published so notification +//! subscribers can alert operators when a provider stream becomes unavailable. //! //! # Why there is no success variant //! diff --git a/shared/src/utils/recording_filename.rs b/shared/src/utils/recording_filename.rs index 52c6ac959..861db9791 100644 --- a/shared/src/utils/recording_filename.rs +++ b/shared/src/utils/recording_filename.rs @@ -67,49 +67,30 @@ impl RecordingFilenameContext { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RecordingFilenameError { + #[error("recording filename template is empty")] Empty, - TooLong { - bytes: usize, - }, + #[error("recording filename exceeds {MAX_RECORDING_STEM_BYTES} bytes (got {bytes})")] + TooLong { bytes: usize }, + #[error("unknown placeholder '{0}' in recording filename template")] UnknownPlaceholder(String), + #[error("recording filename template has an unmatched '{{'")] UnmatchedOpenBrace, + #[error("recording filename template has an unmatched '}}'")] UnmatchedCloseBrace, + #[error("recording filename template must contain at least one placeholder")] NoPlaceholder, /// Template contains a literal path separator (`/` or `\`). Path /// separators must never reach the rendered stem — they would /// escape the recording directory or collide with the partial-file /// suffix machinery. + #[error("recording filename template contains path separator '{0}'")] PathSeparator(char), + #[error("recording timestamp {0} is out of range for the configured timezone")] TimeOutOfRange(String), } -impl fmt::Display for RecordingFilenameError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => f.write_str("recording filename template is empty"), - Self::TooLong { bytes } => { - write!(f, "recording filename exceeds {MAX_RECORDING_STEM_BYTES} bytes (got {bytes})") - } - Self::UnknownPlaceholder(name) => { - write!(f, "unknown placeholder '{name}' in recording filename template") - } - Self::UnmatchedOpenBrace => f.write_str("recording filename template has an unmatched '{'"), - Self::UnmatchedCloseBrace => f.write_str("recording filename template has an unmatched '}'"), - Self::NoPlaceholder => f.write_str("recording filename template must contain at least one placeholder"), - Self::PathSeparator(sep) => { - write!(f, "recording filename template contains path separator '{sep}'") - } - Self::TimeOutOfRange(what) => { - write!(f, "recording timestamp {what} is out of range for the configured timezone") - } - } - } -} - -impl std::error::Error for RecordingFilenameError {} - /// Validate a recording filename template. Returns `Ok(())` when the /// template is acceptable. Mirrors the validation in /// `video_download.rs::prepare_recording_config` so the two cannot