mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
* fix(dvr): correct source resolution, HLS headers and proxy routing * fix(dvr): support concurrent live recordings within provider capacity
5313 lines
217 KiB
Rust
5313 lines
217 KiB
Rust
pub use crate::repository::{
|
|
evaluate_network_access, log_network_access_allowed_geoip_unavailable, log_network_access_denied,
|
|
NetworkAccessDecision, NetworkAccessDenyReason,
|
|
};
|
|
use crate::{
|
|
api::{
|
|
endpoints::{
|
|
hls_api::{hls_media_playlist_wrap_enabled, wrap_media_playlist, HlsMediaPlaylistWrap},
|
|
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, get_custom_stream_response_error_status,
|
|
get_stream_response_with_headers, is_custom_video_stream_enabled, open_provider_stream_with_lifecycle,
|
|
tee_stream, AppState, BoxedProviderStream, CustomVideoStreamType, PendingProviderReason, PlaybackLeaseRef,
|
|
ProviderAllocation, ProviderConfig, ProviderStreamCustomReason, ProviderStreamFactoryOptions,
|
|
ProviderStreamInfo, ProviderStreamOpenLifecycle, ProviderStreamState, SharedStreamCtx, SharedStreamManager,
|
|
StreamAdmissionError, 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, ConfigInputFlags, ConfigTarget, InputUserInfo, PlaybackKind, ProxyUserCredentials,
|
|
},
|
|
processing::{
|
|
parser::hls::{classify_hls_playlist, rewrite_hls, HlsPlaylistKind, RewriteHlsProps},
|
|
processor::re_resolve_stalker_url,
|
|
},
|
|
repository::{load_first_input_m3u_stream_url_by_keys, load_input_m3u_stream_url},
|
|
utils::{
|
|
async_file_reader, async_file_writer, classify_output_resource_url, classify_resource_hop,
|
|
create_new_file_for_write, debug_if_enabled, get_file_extension, request,
|
|
request::{
|
|
classify_resource_destination, content_type_from_ext, parse_range, send_with_retry_and_provider,
|
|
ResourceDestination,
|
|
},
|
|
trace_if_enabled, ResourceHop,
|
|
},
|
|
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,
|
|
ResourceOutputPolicy, 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_account_query_key, is_sanitize_sensitive_info_enabled, m3u_flussonic_live_file_lookup_key,
|
|
m3u_stream_url_identity, 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_sanitize_sensitive_info_enabled() {
|
|
return Cow::Borrowed(stream_url);
|
|
}
|
|
if is_media_server_playback_url(input, stream_url) {
|
|
return Cow::Borrowed("media-server://<redacted>");
|
|
}
|
|
|
|
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<AppState>,
|
|
pub fingerprint: &'a Fingerprint,
|
|
pub user: &'a ProxyUserCredentials,
|
|
pub stream_channel: StreamChannel,
|
|
pub provider_name: Arc<str>,
|
|
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<CustomVideoStreamType> {
|
|
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<AppState>,
|
|
fingerprint: &Fingerprint,
|
|
user: &ProxyUserCredentials,
|
|
stream_channel: StreamChannel,
|
|
provider_name: Arc<str>,
|
|
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()
|
|
}
|
|
|
|
/// Produces a defined non-success response for a rejected playback request. Unlike the
|
|
/// upstream provider response, this carries no Content-Length/Content-Range so downstream
|
|
/// players receive an unambiguous error instead of an empty success body.
|
|
fn stream_admission_rejected_response(error: StreamAdmissionError, username: &str) -> axum::response::Response {
|
|
error!("Stream admission rejected for user {username}: {error:?}");
|
|
axum::response::Response::builder()
|
|
.status(StatusCode::SERVICE_UNAVAILABLE)
|
|
.header("x-tuliprox-rejection", "admission_rejected")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap_or_else(|_| StatusCode::SERVICE_UNAVAILABLE.into_response())
|
|
}
|
|
|
|
/// Produces a quiet termination response when a recently evicted stream retries.
|
|
pub fn reentry_suppressed_response() -> axum::response::Response { StatusCode::NO_CONTENT.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, StreamResponseMode},
|
|
};
|
|
use tuliprox_session::{ProviderSessionHeaders, SessionProviderHeaders};
|
|
|
|
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<std::time::Instant> = 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<String> {
|
|
BUILD_TIMESTAMP
|
|
.to_string()
|
|
.parse::<DateTime<Utc>>()
|
|
.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(Default)]
|
|
struct StreamMeteringConfig {
|
|
meter_uid: u32,
|
|
meter_stream: bool,
|
|
pending_shared_registration: Option<tuliprox_session::PendingSharedMeterRegistration>,
|
|
}
|
|
|
|
impl StreamMeteringConfig {
|
|
fn commit_shared_registration(&mut self) {
|
|
if let Some(registration) = self.pending_shared_registration.take() {
|
|
registration.commit();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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<Utc> = 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<AppState>,
|
|
) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
|
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<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
|
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<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
|
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<str>>,
|
|
allow_forced_provider_fallback: bool,
|
|
allow_provider_grace: bool,
|
|
user_priority: i8,
|
|
connection_kind: crate::api::model::ConnectionKind,
|
|
session_owner: Option<&'a str>,
|
|
playback_kind: PlaybackKind,
|
|
accept_requested_stream_url: bool,
|
|
capacity_wait_timeout: Option<Duration>,
|
|
}
|
|
|
|
pub struct ForceStreamRequestContext<'a> {
|
|
pub req_headers: &'a HeaderMap,
|
|
pub input: &'a Arc<ConfigInput>,
|
|
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<PlaybackRequestClass>,
|
|
virtual_id: VirtualId,
|
|
item_type: PlaylistItemType,
|
|
stream_url: &'a str,
|
|
connection_permission: UserConnectionPermission,
|
|
connection_kind: crate::api::model::ConnectionKind,
|
|
granted_grace_mode: Option<crate::api::model::GraceMode>,
|
|
socket_bound: bool,
|
|
}
|
|
|
|
struct PlaybackActivationResult {
|
|
admission: crate::api::model::ConnectionAdmission,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
grace_context: Option<crate::api::model::GraceResolutionContext>,
|
|
placeholder_transition_version: Option<u64>,
|
|
}
|
|
|
|
/// # Panics
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn activate_session_before_stream_open(
|
|
app_state: &Arc<AppState>,
|
|
request: SessionActivationRequest<'_>,
|
|
) -> PlaybackActivationResult {
|
|
let SessionActivationRequest {
|
|
fingerprint,
|
|
input,
|
|
user,
|
|
session_token,
|
|
request_class,
|
|
virtual_id,
|
|
item_type,
|
|
stream_url,
|
|
connection_permission,
|
|
connection_kind,
|
|
granted_grace_mode,
|
|
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, loaded_session) = 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;
|
|
let classified = classify_playback_request(PlaybackRequestFacts {
|
|
existing_session: current_session.as_ref(),
|
|
prepare_only: false,
|
|
terminate: false,
|
|
});
|
|
(classified, Some(current_session))
|
|
} else {
|
|
(request_class, None)
|
|
}
|
|
} else {
|
|
let existing_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
|
|
let classified = classify_playback_request(PlaybackRequestFacts {
|
|
existing_session: existing_session.as_ref(),
|
|
prepare_only: false,
|
|
terminate: false,
|
|
});
|
|
(classified, Some(existing_session))
|
|
};
|
|
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 {
|
|
if loaded_session.as_ref().is_none_or(Option::is_none) {
|
|
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;
|
|
}
|
|
// Materialize grace lifecycle under the guard so the session state is consistent.
|
|
let current_session = match loaded_session {
|
|
Some(Some(session)) => Some(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) => {
|
|
(crate::api::model::PlaybackLifecycle::GraceActive, Some(crate::api::model::GraceMode::Instant))
|
|
}
|
|
_ => {
|
|
let hold_stream = granted_grace_mode.map_or_else(
|
|
|| item_type.is_live() || item_type.is_live_adaptive(),
|
|
|mode| matches!(mode, crate::api::model::GraceMode::Hold),
|
|
);
|
|
if hold_stream {
|
|
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::from_permission(
|
|
connection_permission,
|
|
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::from_permission(
|
|
connection_permission,
|
|
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<ProviderConfig>,
|
|
) -> Option<String> {
|
|
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<ProviderConfig>,
|
|
) -> Option<String> {
|
|
if let Some(source_config_url) = find_input_account_by_query_signature(stream_url, input) {
|
|
return rewrite_account_query_fields(stream_url, source_config_url, &alias_input.url);
|
|
}
|
|
|
|
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 find_input_account_by_query_signature<'a>(stream_url: &str, input: &'a ConfigInput) -> Option<&'a str> {
|
|
if stream_url_account_query_matches(stream_url, &input.url) {
|
|
return Some(&input.url);
|
|
}
|
|
|
|
input.aliases.as_ref().and_then(|aliases| {
|
|
aliases
|
|
.iter()
|
|
.find(|alias| stream_url_account_query_matches(stream_url, &alias.url))
|
|
.map(|alias| alias.url.as_str())
|
|
})
|
|
}
|
|
|
|
fn stream_url_account_query_matches(stream_url: &str, config_url: &str) -> bool {
|
|
let (Ok(stream_url), Ok(config_url)) = (Url::parse(stream_url), Url::parse(config_url)) else {
|
|
return false;
|
|
};
|
|
|
|
config_url.query_pairs().any(|(config_key, config_value)| {
|
|
is_account_query_key(&config_key)
|
|
&& stream_url.query_pairs().any(|(stream_key, stream_value)| {
|
|
stream_key.eq_ignore_ascii_case(&config_key) && stream_value == config_value
|
|
})
|
|
})
|
|
}
|
|
|
|
fn rewrite_account_query_fields(stream_url: &str, source_config_url: &str, target_config_url: &str) -> Option<String> {
|
|
let mut stream_url = Url::parse(stream_url).ok()?;
|
|
let source_config_url = Url::parse(source_config_url).ok()?;
|
|
let target_config_url = Url::parse(target_config_url).ok()?;
|
|
let source_pairs: Vec<_> = source_config_url
|
|
.query_pairs()
|
|
.filter(|(key, _)| is_account_query_key(key))
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect();
|
|
let target_pairs: Vec<_> = target_config_url
|
|
.query_pairs()
|
|
.filter(|(key, _)| is_account_query_key(key))
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect();
|
|
let unambiguous_cross_key_target =
|
|
if source_pairs.len() == 1 && target_pairs.len() == 1 { target_pairs.first() } else { None };
|
|
let present_source_pairs: Vec<_> = source_pairs
|
|
.iter()
|
|
.filter(|(source_key, source_value)| {
|
|
stream_url.query_pairs().any(|(stream_key, stream_value)| {
|
|
stream_key.eq_ignore_ascii_case(source_key) && stream_value == source_value.as_str()
|
|
})
|
|
})
|
|
.collect();
|
|
let replacements: Vec<_> = present_source_pairs
|
|
.iter()
|
|
.filter_map(|(source_key, source_value)| {
|
|
target_pairs
|
|
.iter()
|
|
.find(|(target_key, _)| target_key.eq_ignore_ascii_case(source_key))
|
|
.or(unambiguous_cross_key_target)
|
|
.map(|(target_key, target_value)| {
|
|
(source_key.clone(), source_value.clone(), target_key.clone(), target_value.clone())
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
if replacements.len() != present_source_pairs.len() || replacements.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut replaced = false;
|
|
let stream_pairs: Vec<(String, String)> = stream_url
|
|
.query_pairs()
|
|
.map(|(key, value)| {
|
|
if let Some((_, _, target_key, target_value)) =
|
|
replacements.iter().find(|(source_key, source_value, _, _)| {
|
|
key.eq_ignore_ascii_case(source_key) && value == source_value.as_str()
|
|
})
|
|
{
|
|
replaced = true;
|
|
(target_key.clone(), target_value.clone())
|
|
} else {
|
|
(key.into_owned(), value.into_owned())
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !replaced {
|
|
return None;
|
|
}
|
|
stream_url
|
|
.query_pairs_mut()
|
|
.clear()
|
|
.extend_pairs(stream_pairs.iter().map(|(key, value)| (key.as_str(), value.as_str())));
|
|
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<String>, Option<String>)> {
|
|
// 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<Vec<String>> {
|
|
url.path_segments().map(|segments| segments.map(ToOwned::to_owned).collect::<Vec<_>>())
|
|
}
|
|
|
|
fn find_path_auth_segment_index(segments: &[String], username: &str, password: &str) -> Option<usize> {
|
|
segments.windows(2).position(|pair| {
|
|
pair.first().is_some_and(|segment| segment == username)
|
|
&& pair.get(1).is_some_and(|segment| segment == password)
|
|
})
|
|
}
|
|
|
|
fn rewrite_path_auth_fields(
|
|
url: &mut Url,
|
|
old_username: &str,
|
|
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 account_query_matches =
|
|
provider_cfg.input_type.is_m3u() && stream_url_account_query_matches(stream_url, &provider_cfg.url);
|
|
let Some(user_info) = provider_cfg.get_user_info() else {
|
|
return account_query_matches;
|
|
};
|
|
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/<user>/<pass>/...). Only flag a signature when the
|
|
// consecutive segments actually match the configured user/pass — arbitrary
|
|
// open paths must not be treated as account signatures.
|
|
if let Some(segments) = collect_path_segments(&url) {
|
|
if find_path_auth_segment_index(&segments, &user_info.username, &user_info.password).is_some() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Maps a Flussonic archive URL of another account onto the alias account.
|
|
///
|
|
/// The URL index only holds live URLs, so an archive URL derived from the main account's
|
|
/// live URL never matches. The archive lives next to the channel's live file, so the alias
|
|
/// URL of that sibling supplies the alias credentials and the archive file name is kept.
|
|
async fn resolve_m3u_alias_flussonic_archive_url(
|
|
app_config: &Arc<AppConfig>,
|
|
provider_name: &Arc<str>,
|
|
stream_url: &str,
|
|
) -> Option<String> {
|
|
let requested = Url::parse(stream_url).ok()?;
|
|
let archive_file = requested.path_segments()?.next_back()?;
|
|
crate::iptv::m3u::parse_flussonic_archive_file(archive_file)?;
|
|
// The live file the archive was derived from is probed first: same stem, then same transport.
|
|
let archive_lower = archive_file.to_ascii_lowercase();
|
|
let archive_is_hls = archive_lower.ends_with(HLS_EXT);
|
|
let archive_stem = archive_lower.split_once('-').map(|(stem, _)| stem);
|
|
let mut live_files = crate::iptv::m3u::FLUSSONIC_LIVE_FILES;
|
|
live_files.sort_by_key(|live_file| {
|
|
let same_stem =
|
|
archive_stem.is_some_and(|stem| live_file.split_once('.').is_some_and(|(name, _)| name == stem));
|
|
let same_transport = live_file.ends_with(HLS_EXT) == archive_is_hls;
|
|
(!same_stem, !same_transport)
|
|
});
|
|
// Each sibling is probed by its exact identity, then by its case-folded Flussonic key.
|
|
let keys: Vec<String> = live_files
|
|
.iter()
|
|
.filter_map(|live_file| {
|
|
let mut sibling = requested.clone();
|
|
sibling.path_segments_mut().ok()?.pop().push(live_file);
|
|
Some([m3u_stream_url_identity(sibling.as_str()), m3u_flussonic_live_file_lookup_key(sibling.as_str())])
|
|
})
|
|
.flatten()
|
|
.flatten()
|
|
.collect();
|
|
let alias_live_url = match load_first_input_m3u_stream_url_by_keys(app_config, provider_name, keys).await {
|
|
Ok(url) => url?,
|
|
Err(err) => {
|
|
debug_if_enabled!(
|
|
"Failed to resolve M3U archive sibling URL for provider {}: {}",
|
|
sanitize_sensitive_info(provider_name),
|
|
sanitize_sensitive_info(&err.to_string())
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let mut alias_url = Url::parse(&alias_live_url).ok()?;
|
|
alias_url.path_segments_mut().ok()?.pop().push(archive_file);
|
|
Some(alias_url.into())
|
|
}
|
|
|
|
pub(crate) async fn select_provider_stream_url(
|
|
stream_url: &str,
|
|
input: &ConfigInput,
|
|
provider_cfg: &Arc<ProviderConfig>,
|
|
accept_requested_stream_url: bool,
|
|
app_config: &Arc<AppConfig>,
|
|
) -> Option<(Arc<str>, String)> {
|
|
if accept_requested_stream_url {
|
|
return Some((provider_cfg.name.clone(), stream_url.to_string()));
|
|
}
|
|
|
|
if provider_cfg.input_type.is_m3u() {
|
|
match load_input_m3u_stream_url(app_config, &provider_cfg.name, stream_url).await {
|
|
Ok(Some(provider_stream_url)) => {
|
|
debug_if_enabled!(
|
|
"M3U alias URL lookup: provider={} requested_url={} index_match=true resolved_url={}",
|
|
sanitize_sensitive_info(&provider_cfg.name),
|
|
sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()),
|
|
sanitize_sensitive_info(resolve_request_url_for_logging(input, &provider_stream_url).as_ref())
|
|
);
|
|
return Some((provider_cfg.name.clone(), provider_stream_url.to_string()));
|
|
}
|
|
Ok(None) => {
|
|
if let Some(provider_stream_url) =
|
|
resolve_m3u_alias_flussonic_archive_url(app_config, &provider_cfg.name, stream_url).await
|
|
{
|
|
debug_if_enabled!(
|
|
"M3U alias URL lookup: provider={} requested_url={} index_match=archive_sibling resolved_url={}",
|
|
sanitize_sensitive_info(&provider_cfg.name),
|
|
sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref()),
|
|
sanitize_sensitive_info(resolve_request_url_for_logging(input, &provider_stream_url).as_ref())
|
|
);
|
|
return Some((provider_cfg.name.clone(), provider_stream_url));
|
|
}
|
|
debug_if_enabled!(
|
|
"M3U alias URL lookup: provider={} requested_url={} index_match=false",
|
|
sanitize_sensitive_info(&provider_cfg.name),
|
|
sanitize_sensitive_info(resolve_request_url_for_logging(input, stream_url).as_ref())
|
|
);
|
|
}
|
|
Err(err) => {
|
|
debug_if_enabled!(
|
|
"Failed to resolve M3U stream URL for provider {}: {}",
|
|
sanitize_sensitive_info(&provider_cfg.name),
|
|
sanitize_sensitive_info(&err.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))
|
|
}
|
|
}
|
|
|
|
/// Stored Xtream VOD entries belong to their named input even when their URL uses a CDN host.
|
|
fn resolve_xtream_vod_provider_url(
|
|
stream_url: &str,
|
|
input: &ConfigInput,
|
|
provider_cfg: &ProviderConfig,
|
|
channel: &StreamChannel,
|
|
) -> Option<String> {
|
|
if input.input_type != InputType::Xtream
|
|
|| provider_cfg.input_type != InputType::Xtream
|
|
|| channel.item_type != PlaylistItemType::Video
|
|
|| channel.input_name != input.name
|
|
|| channel.url.as_ref() != stream_url
|
|
|| channel.provider_id == 0
|
|
{
|
|
return None;
|
|
}
|
|
let url = Url::parse(stream_url).ok()?;
|
|
if !matches!(url.scheme(), "http" | "https") {
|
|
return None;
|
|
}
|
|
let is_main = provider_cfg.name == input.name;
|
|
if !is_main
|
|
&& !input.aliases.as_ref().is_some_and(|aliases| aliases.iter().any(|alias| alias.name == provider_cfg.name))
|
|
{
|
|
return None;
|
|
}
|
|
let selected = provider_cfg.get_user_info()?;
|
|
let origin = input.get_user_info()?;
|
|
if is_main
|
|
&& selected.base_url == origin.base_url
|
|
&& selected.username == origin.username
|
|
&& selected.password == origin.password
|
|
{
|
|
return Some(stream_url.to_string());
|
|
}
|
|
|
|
// A direct source may carry account-bound tokens. A different account obtains its own redirect.
|
|
let extension = channel
|
|
.technical
|
|
.as_ref()
|
|
.map(|technical| technical.container.as_str())
|
|
.filter(|container| !container.is_empty())
|
|
.map(|container| match container {
|
|
"mpegts" => "ts",
|
|
"hls" => "m3u8",
|
|
"dash" => "mpd",
|
|
other => other,
|
|
})
|
|
.or_else(|| extract_extension_from_url(&channel.url).and_then(|extension| extension.strip_prefix('.')));
|
|
let base_url = selected.base_url.trim_end_matches('/');
|
|
let mut url = format!("{base_url}/movie/{}/{}/{}", selected.username, selected.password, channel.provider_id);
|
|
if let Some(extension) = extension {
|
|
url.push('.');
|
|
url.push_str(extension);
|
|
}
|
|
Some(url)
|
|
}
|
|
|
|
fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState {
|
|
ProviderStreamState::Custom {
|
|
response: create_channel_unavailable_stream(app_config, &[], StatusCode::OK),
|
|
reason: ProviderStreamCustomReason::UnmappedProviderUrl,
|
|
}
|
|
}
|
|
|
|
/// Splits a provider open into stream, response info and session headers. An upstream error
|
|
/// of a finite HLS resource carries no stream, only its status and forwarded headers.
|
|
fn split_provider_stream_open(
|
|
open: crate::api::model::ProviderStreamOpen,
|
|
) -> (Option<BoxedProviderStream>, ProviderStreamInfo, ProviderSessionHeaders) {
|
|
match open {
|
|
crate::api::model::ProviderStreamOpen::Stream(response) => {
|
|
(Some(response.stream), response.info, response.provider_session_headers)
|
|
}
|
|
crate::api::model::ProviderStreamOpen::UpstreamStatus { status, headers } => {
|
|
(None, Some((headers, status, None, None)), ProviderSessionHeaders::default())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session and account binding a forced resource request must still belong to after acquiring capacity.
|
|
#[derive(Clone, Copy)]
|
|
struct CurrentSessionGuard<'a> {
|
|
username: &'a str,
|
|
token: &'a str,
|
|
identity: tuliprox_session::SessionIdentity,
|
|
}
|
|
|
|
/// Upper bound a manifest refresh waits for its pinned account to free a slot. Parallel
|
|
/// playbacks on one account free their slots within seconds; a 503 would stall the player.
|
|
pub(crate) const HLS_MANIFEST_CAPACITY_WAIT: Duration = Duration::from_secs(5);
|
|
/// Upper bound a media object (segment, init, key) waits. It stays well below a typical
|
|
/// segment duration, so the player keeps buffer to retry after a 503.
|
|
const HLS_MEDIA_CAPACITY_WAIT: Duration = Duration::from_secs(2);
|
|
/// Lower bound between two acquisition attempts that only wait for a lease to expire.
|
|
const LEASE_EXPIRY_RECHECK_FLOOR: Duration = Duration::from_millis(10);
|
|
|
|
/// Acquisition of a slot on exactly one provider account for a playback lease.
|
|
pub(crate) struct ExactProviderAcquire<'a> {
|
|
pub provider: &'a Arc<str>,
|
|
pub addr: &'a SocketAddr,
|
|
pub allow_grace: bool,
|
|
pub priority: i8,
|
|
pub kind: crate::api::model::ConnectionKind,
|
|
pub lease: Option<PlaybackLeaseRef<'a>>,
|
|
}
|
|
|
|
/// Acquires a slot on exactly `request.provider`, waiting at most `capacity_wait` for one to
|
|
/// free up. Only slot and lease releases of this provider wake the waiter; reserved capacity
|
|
/// held by idle leases is retried when the next lease expires.
|
|
pub(crate) async fn acquire_exact_provider_handle(
|
|
app_state: &Arc<AppState>,
|
|
request: &ExactProviderAcquire<'_>,
|
|
capacity_wait: Option<Duration>,
|
|
) -> Option<crate::api::model::ProviderHandle> {
|
|
let deadline = capacity_wait.map(|wait| tokio::time::Instant::now() + wait);
|
|
let acquire = || {
|
|
app_state.active_provider.acquire_exact_connection_with_lease_for_session_until(
|
|
request.provider,
|
|
request.addr,
|
|
request.allow_grace,
|
|
request.priority,
|
|
request.kind,
|
|
request.lease,
|
|
deadline,
|
|
)
|
|
};
|
|
let Some(deadline) = deadline else {
|
|
return acquire().await;
|
|
};
|
|
let capacity_notify = app_state.active_provider.provider_capacity_notify(request.provider);
|
|
loop {
|
|
let notified = capacity_notify.notified();
|
|
tokio::pin!(notified);
|
|
notified.as_mut().enable();
|
|
if app_state.connection_manager.is_shutting_down() || tokio::time::Instant::now() >= deadline {
|
|
return None;
|
|
}
|
|
if let Some(handle) = acquire().await {
|
|
return Some(handle);
|
|
}
|
|
let earliest_retry = tokio::time::Instant::now() + LEASE_EXPIRY_RECHECK_FLOOR;
|
|
let wake_at = app_state
|
|
.active_provider
|
|
.next_lease_expiry()
|
|
.map_or(deadline, |expiry| expiry.max(earliest_retry).min(deadline));
|
|
tokio::select! {
|
|
() = tokio::time::sleep_until(wake_at) => {},
|
|
() = &mut notified => {},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Waits for finite resources to release capacity without superseding an active body.
|
|
async fn acquire_exact_stream_provider_handle(
|
|
app_state: &Arc<AppState>,
|
|
provider: &Arc<str>,
|
|
fingerprint: &Fingerprint,
|
|
options: &StreamingAcquireOptions<'_>,
|
|
) -> Option<crate::api::model::ProviderHandle> {
|
|
let request = ExactProviderAcquire {
|
|
provider,
|
|
addr: &fingerprint.addr,
|
|
allow_grace: options.allow_provider_grace,
|
|
priority: options.user_priority,
|
|
kind: options.connection_kind,
|
|
lease: options.session_owner.map(|owner| PlaybackLeaseRef::new(owner, options.playback_kind)),
|
|
};
|
|
acquire_exact_provider_handle(app_state, &request, options.capacity_wait_timeout).await
|
|
}
|
|
|
|
async fn acquire_stream_provider_handle(
|
|
app_state: &Arc<AppState>,
|
|
input: &ConfigInput,
|
|
fingerprint: &Fingerprint,
|
|
options: &StreamingAcquireOptions<'_>,
|
|
) -> Option<tuliprox_session::ManagedProviderHandle> {
|
|
let lease = options.session_owner.map(|owner| PlaybackLeaseRef::new(owner, options.playback_kind));
|
|
let managed = |handle| tuliprox_session::ManagedProviderHandle::new(Arc::clone(&app_state.active_provider), handle);
|
|
match options.force_provider {
|
|
Some(provider) => {
|
|
// First try to stay on the exact pinned provider account without over-allocating.
|
|
if let Some(handle) = acquire_exact_stream_provider_handle(app_state, provider, fingerprint, options).await
|
|
{
|
|
Some(managed(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_lease_for_session_await(
|
|
&input.name,
|
|
&fingerprint.addr,
|
|
options.allow_provider_grace,
|
|
options.user_priority,
|
|
options.connection_kind,
|
|
lease,
|
|
)
|
|
.await
|
|
.map(managed)
|
|
} 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_lease_for_session_await(
|
|
&input.name,
|
|
&fingerprint.addr,
|
|
options.allow_provider_grace,
|
|
options.user_priority,
|
|
options.connection_kind,
|
|
lease,
|
|
)
|
|
.await
|
|
.map(managed),
|
|
}
|
|
}
|
|
|
|
fn allows_provider_pool_failover(item_type: PlaylistItemType) -> bool {
|
|
matches!(
|
|
item_type,
|
|
PlaylistItemType::Video
|
|
| PlaylistItemType::LocalVideo
|
|
| PlaylistItemType::Series
|
|
| PlaylistItemType::SeriesInfo
|
|
| PlaylistItemType::LocalSeries
|
|
| PlaylistItemType::LocalSeriesInfo
|
|
)
|
|
}
|
|
|
|
pub(crate) fn resolve_redirect_location<'a>(
|
|
input: Option<&ConfigInput>,
|
|
stream_url: &'a str,
|
|
) -> Result<Cow<'a, str>, TuliproxError> {
|
|
input.map_or(Ok(Cow::Borrowed(stream_url)), |input| input.resolve_url(stream_url))
|
|
}
|
|
|
|
fn get_redirect_alternative_url(app_state: &Arc<AppState>, redirect_url: &Arc<str>, input: &ConfigInput) -> Arc<str> {
|
|
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) {
|
|
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.
|
|
#[cfg(test)]
|
|
async fn resolve_streaming_strategy(
|
|
app_state: &Arc<AppState>,
|
|
stream_url: &str,
|
|
fingerprint: &Fingerprint,
|
|
input: &ConfigInput,
|
|
options: StreamingAcquireOptions<'_>,
|
|
stream_channel: Option<&StreamChannel>,
|
|
) -> StreamingStrategy {
|
|
resolve_streaming_strategy_with_provider_handle(
|
|
app_state,
|
|
stream_url,
|
|
fingerprint,
|
|
input,
|
|
options,
|
|
stream_channel,
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn resolve_streaming_strategy_with_provider_handle(
|
|
app_state: &Arc<AppState>,
|
|
stream_url: &str,
|
|
fingerprint: &Fingerprint,
|
|
input: &ConfigInput,
|
|
options: StreamingAcquireOptions<'_>,
|
|
stream_channel: Option<&StreamChannel>,
|
|
preacquired_provider_handle: Option<tuliprox_session::ManagedProviderHandle>,
|
|
) -> StreamingStrategy {
|
|
// Recording requests transfer the slot acquired by the worker into this
|
|
// provider-body owner. Normal playback allocates here as before.
|
|
let mut provider_connection_handle = match preacquired_provider_handle {
|
|
Some(handle) => Some(handle),
|
|
None => 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().and_then(|managed| managed.handle()).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) => {
|
|
// If a forced/pinned provider was requested but allocation fell back to another account,
|
|
// the session's stream_url still points to the old provider and cannot be accepted as-is;
|
|
// it must be resolved or rewritten for the newly allocated provider account.
|
|
let accept_requested_stream_url = (options.accept_requested_stream_url
|
|
|| input.input_type.is_stalker())
|
|
&& options.force_provider.is_none_or(|forced| forced.as_ref() == provider_cfg.name.as_ref());
|
|
// 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.
|
|
let selected_stream = select_provider_stream_url(
|
|
stream_url,
|
|
input,
|
|
provider_cfg,
|
|
accept_requested_stream_url,
|
|
&app_state.app_config,
|
|
)
|
|
.await
|
|
.or_else(|| {
|
|
let url = resolve_xtream_vod_provider_url(stream_url, input, provider_cfg, stream_channel?)?;
|
|
Some((Arc::clone(&provider_cfg.name), url))
|
|
});
|
|
if let Some((selected_provider_name, url)) = selected_stream {
|
|
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 {
|
|
// The managed owner releases the provider slot synchronously on drop; no
|
|
// task-per-drop and no lossy cleanup message.
|
|
drop(provider_connection_handle.take());
|
|
}
|
|
|
|
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<StatusCode>) -> 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<AppState>,
|
|
input: &ConfigInput,
|
|
provider_id: u32,
|
|
kind: StalkerStreamKind,
|
|
force_refresh: bool,
|
|
) -> Result<Option<Arc<str>>, 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_clients.default.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<AppState>,
|
|
input: &ConfigInput,
|
|
provider_id: u32,
|
|
cluster: XtreamCluster,
|
|
item_type: PlaylistItemType,
|
|
stream_url: &Arc<str>,
|
|
) -> Result<Arc<str>, 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
|
|
))
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn resolve_stream_user_agent_index(
|
|
app_state: &AppState,
|
|
input: &ConfigInput,
|
|
has_provider_handle: bool,
|
|
username: &str,
|
|
session_owner: Option<&str>,
|
|
) -> Option<u64> {
|
|
if input.has_flag(ConfigInputFlags::UserAgentStreamIndex) && has_provider_handle {
|
|
if let Some(session_token) = session_owner {
|
|
app_state
|
|
.active_users
|
|
.get_or_assign_user_agent_stream_index(username, session_token)
|
|
.await
|
|
.or_else(|| Some(app_state.active_users.next_user_agent_stream_index()))
|
|
} else {
|
|
Some(app_state.active_users.next_user_agent_stream_index())
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines, clippy::fn_params_excessive_bools)]
|
|
async fn create_stream_response_details(
|
|
app_state: &Arc<AppState>,
|
|
stream_options: &StreamOptions,
|
|
stream_url: &str,
|
|
username: &str,
|
|
fingerprint: &Fingerprint,
|
|
req_headers: &HeaderMap,
|
|
input: &Arc<ConfigInput>,
|
|
stream_channel: &StreamChannel,
|
|
item_type: PlaylistItemType,
|
|
content_representation: crate::api::model::ProviderContentRepresentationMode,
|
|
share_stream: bool,
|
|
connection_permission: UserConnectionPermission,
|
|
force_provider: Option<&Arc<str>>,
|
|
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<String, String>>,
|
|
accept_requested_stream_url: bool,
|
|
grace_hold_override: Option<bool>,
|
|
grace_resolution_context: Option<crate::api::model::GraceResolutionContext>,
|
|
current_session: Option<CurrentSessionGuard<'_>>,
|
|
preacquired_provider_handle: Option<tuliprox_session::ManagedProviderHandle>,
|
|
) -> Result<StreamDetails, TuliproxError> {
|
|
let mut streaming_strategy = resolve_streaming_strategy_with_provider_handle(
|
|
app_state,
|
|
stream_url,
|
|
fingerprint,
|
|
input,
|
|
StreamingAcquireOptions {
|
|
force_provider,
|
|
allow_forced_provider_fallback,
|
|
allow_provider_grace,
|
|
user_priority,
|
|
connection_kind,
|
|
session_owner,
|
|
playback_kind: PlaybackKind::classify(item_type, extract_extension_from_url(stream_url)),
|
|
accept_requested_stream_url,
|
|
capacity_wait_timeout: (stream_options.response_mode == StreamResponseMode::HlsResource)
|
|
.then_some(HLS_MEDIA_CAPACITY_WAIT),
|
|
},
|
|
Some(stream_channel),
|
|
preacquired_provider_handle,
|
|
)
|
|
.await;
|
|
// A capacity wait can outlive the session snapshot: revalidate the account binding and
|
|
// read the cookies valid now, before any upstream request is sent.
|
|
let current_session_headers;
|
|
let session_headers = match current_session {
|
|
Some(guard) => {
|
|
match app_state
|
|
.active_users
|
|
.current_session_provider_headers(guard.username, guard.token, guard.identity, stream_url)
|
|
.await
|
|
{
|
|
SessionProviderHeaders::NoSession => {
|
|
app_state
|
|
.connection_manager
|
|
.release_managed_provider_handle(streaming_strategy.provider_handle.take());
|
|
return Err(TuliproxError::Errors("playback session ended or switched provider account"));
|
|
}
|
|
SessionProviderHeaders::NoHeaders => current_session_headers = None,
|
|
SessionProviderHeaders::Headers(headers) => current_session_headers = Some(headers),
|
|
}
|
|
current_session_headers.as_ref()
|
|
}
|
|
None => session_headers,
|
|
};
|
|
let user_agent_stream_index = resolve_stream_user_agent_index(
|
|
app_state,
|
|
input,
|
|
streaming_strategy.provider_handle.is_some(),
|
|
username,
|
|
session_owner,
|
|
)
|
|
.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(|managed| managed.handle())
|
|
.and_then(|handle| handle.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(managed) = streaming_strategy.provider_handle.take() {
|
|
drop(managed);
|
|
}
|
|
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, reason } => {
|
|
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 {
|
|
shared_subscriber_id: None,
|
|
stream,
|
|
stream_info,
|
|
provider_name: Some(provider_name),
|
|
request_url: None,
|
|
session_headers: session_headers.cloned(),
|
|
provider_session_headers: ProviderSessionHeaders::default(),
|
|
user_agent_stream_index,
|
|
grace_period: grace_period_options,
|
|
provider_grace_active: false,
|
|
disable_provider_grace: false,
|
|
reconnect_flag: None,
|
|
provider_handle: streaming_strategy.provider_handle.take(),
|
|
content_representation,
|
|
grace_resolution_context,
|
|
custom_reason: Some(reason),
|
|
response_mode: stream_options.response_mode,
|
|
session_registration: None,
|
|
})
|
|
}
|
|
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)
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
let is_fallback_provider = force_provider
|
|
.is_some_and(|forced| guard_provider_name.as_ref().is_some_and(|allocated| allocated != forced));
|
|
let session_headers = if is_fallback_provider { None } else { session_headers };
|
|
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, ProviderSessionHeaders::default(), 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, ProviderSessionHeaders::default(), None)
|
|
}
|
|
Err(err) => {
|
|
error!("Can't open media-server stream: {err}");
|
|
(None, None, ProviderSessionHeaders::default(), 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,
|
|
});
|
|
|
|
if let Some(stream_index) = user_agent_stream_index {
|
|
provider_stream_factory_options.apply_user_agent_stream_index(stream_index);
|
|
}
|
|
|
|
let provider_config = input.get_resolve_provider(url.as_ref());
|
|
provider_stream_factory_options.set_provider(provider_config);
|
|
provider_stream_factory_options.apply_input_options(input);
|
|
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 lifecycle = streaming_strategy
|
|
.provider_handle
|
|
.as_ref()
|
|
.and_then(ProviderStreamOpenLifecycle::from_managed);
|
|
let provider_stream = match open_provider_stream_with_lifecycle(
|
|
&app_state.provider_stream_ctx(),
|
|
&app_state.http_clients.default.load(),
|
|
provider_stream_factory_options,
|
|
lifecycle,
|
|
)
|
|
.await
|
|
{
|
|
None => (None, None, ProviderSessionHeaders::default()),
|
|
Some(open) => split_provider_stream_open(open),
|
|
};
|
|
(provider_stream, Some(reconnect_flag))
|
|
} else {
|
|
((None, None, ProviderSessionHeaders::default()), 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,
|
|
},
|
|
);
|
|
if let Some(stream_index) = user_agent_stream_index {
|
|
options.apply_user_agent_stream_index(stream_index);
|
|
}
|
|
options.set_provider(input.get_resolve_provider(url.as_ref()));
|
|
options.apply_input_options(input);
|
|
options.require_public_destination();
|
|
if let Some(m) = streaming_strategy.provider_handle.as_mut() {
|
|
let _ = m.renew_opening_tokens();
|
|
}
|
|
let retry_reconnect_flag = options.get_reconnect_flag_clone();
|
|
let retry_lifecycle = streaming_strategy
|
|
.provider_handle
|
|
.as_ref()
|
|
.and_then(ProviderStreamOpenLifecycle::from_managed);
|
|
let retried = open_provider_stream_with_lifecycle(
|
|
&app_state.provider_stream_ctx(),
|
|
&app_state.http_clients.default.load(),
|
|
options,
|
|
retry_lifecycle,
|
|
)
|
|
.await;
|
|
if let Some(open) = retried {
|
|
(stream, stream_info, provider_session_headers) =
|
|
split_provider_stream_open(open);
|
|
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 is_fallback_provider && stream.is_some() {
|
|
if let Some(token) = session_owner {
|
|
let _ =
|
|
app_state.active_users.update_session_provider_headers(username, token, &HashMap::new()).await;
|
|
}
|
|
}
|
|
|
|
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 failed_open = stream.is_none()
|
|
|| matches!(stream_info.as_ref(), Some((_, _, _, Some(custom))) if *custom != CustomVideoStreamType::Provisioning);
|
|
let provider_handle = if failed_open && !defer_provider_stream_until_grace_check {
|
|
// The managed owner releases the provider slot synchronously on drop;
|
|
// a failed open must not rely on a lossy cleanup message.
|
|
drop(streaming_strategy.provider_handle.take());
|
|
match stream_info.as_ref() {
|
|
// Finite HLS resources hand routine upstream errors (e.g. live-edge 404) to the client.
|
|
Some((_, status, _, None)) if stream.is_none() && !status.is_success() => {
|
|
debug!("Provider answered {status} for {}", sanitize_sensitive_info(&request_url));
|
|
}
|
|
_ => error!("Can't open stream {}", sanitize_sensitive_info(&request_url)),
|
|
}
|
|
None
|
|
} else {
|
|
streaming_strategy.provider_handle.take()
|
|
};
|
|
|
|
Ok(StreamDetails {
|
|
shared_subscriber_id: None,
|
|
stream,
|
|
stream_info,
|
|
provider_name: guard_provider_name.clone(),
|
|
request_url: Some(request_url.clone()),
|
|
session_headers: session_headers.cloned(),
|
|
provider_session_headers,
|
|
user_agent_stream_index,
|
|
grace_period: grace_period_options,
|
|
provider_grace_active,
|
|
disable_provider_grace: false,
|
|
reconnect_flag,
|
|
provider_handle,
|
|
content_representation,
|
|
grace_resolution_context,
|
|
custom_reason: None,
|
|
response_mode: stream_options.response_mode,
|
|
session_registration: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RedirectParams<'a, P>
|
|
where
|
|
P: PlaylistEntry,
|
|
{
|
|
pub item: &'a P,
|
|
pub provider_id: Option<u32>,
|
|
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<P> 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<AppState>,
|
|
params: &'a RedirectParams<'a, P>,
|
|
) -> Option<impl IntoResponse + Send>
|
|
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);
|
|
|
|
// Recording playback keeps provider headers and proxy handling on this listener.
|
|
// DASH redirects because relative MPD resource URLs are not rewritten.
|
|
if params.user.is_recording_proxy_user() && !is_dash_request {
|
|
return None;
|
|
}
|
|
|
|
if params.target_type == TargetType::M3u {
|
|
if redirect_request || is_dash_request {
|
|
let redirect_url: Arc<str> = 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);
|
|
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 || is_dash_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) {
|
|
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<S>(app_state: &Arc<AppState>, item_type: PlaylistItemType, stream: S) -> axum::body::Body
|
|
where
|
|
S: futures::Stream<Item = Result<bytes::Bytes, StreamError>> + 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<AppState>,
|
|
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_clients.default.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::<Vec<_>>();
|
|
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)
|
|
}
|
|
}
|
|
|
|
pub async fn force_provider_stream_response(
|
|
fingerprint: &Fingerprint,
|
|
app_state: &Arc<AppState>,
|
|
user_session: &UserSession,
|
|
stream_channel: StreamChannel,
|
|
ctx: ForceStreamRequestContext<'_>,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
) -> axum::response::Response {
|
|
force_stream_response(
|
|
fingerprint,
|
|
app_state,
|
|
user_session,
|
|
stream_channel,
|
|
ctx,
|
|
grace_mode,
|
|
StreamResponseMode::Stream,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn force_hls_resource_response(
|
|
fingerprint: &Fingerprint,
|
|
app_state: &Arc<AppState>,
|
|
user_session: &UserSession,
|
|
stream_channel: StreamChannel,
|
|
mut ctx: ForceStreamRequestContext<'_>,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
) -> axum::response::Response {
|
|
// `resolve_stream_channel` classified the item with `hls_playback_kind`, like the manifest paths.
|
|
let kind = if stream_channel.item_type == PlaylistItemType::Catchup {
|
|
PlaybackKind::Catchup
|
|
} else {
|
|
PlaybackKind::LiveHls
|
|
};
|
|
ctx.session_reservation_ttl_secs = get_hls_playback_ttl_secs(app_state, kind);
|
|
force_stream_response(
|
|
fingerprint,
|
|
app_state,
|
|
user_session,
|
|
stream_channel,
|
|
ctx,
|
|
grace_mode,
|
|
StreamResponseMode::HlsResource,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Status a finite HLS resource answers with instead of a fallback body, or `None` on success.
|
|
fn hls_resource_failure_status(stream_details: &StreamDetails) -> Option<StatusCode> {
|
|
match stream_details.stream_info.as_ref() {
|
|
Some((_, status, _, _)) if !status.is_success() => Some(*status),
|
|
Some((_, _, _, Some(_))) => Some(StatusCode::SERVICE_UNAVAILABLE),
|
|
_ if stream_details.custom_reason.is_some() => Some(StatusCode::SERVICE_UNAVAILABLE),
|
|
_ if !stream_details.has_stream() => Some(StatusCode::BAD_GATEWAY),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Answers a failed finite HLS resource with its status and the forwarded upstream headers.
|
|
/// Its provider slot is freed and an entry reservation no body ever used is released; a session
|
|
/// that streams elsewhere keeps its reservation (see `release_unbound_session_reservation`).
|
|
async fn reject_hls_resource(
|
|
app_state: &Arc<AppState>,
|
|
username: &str,
|
|
session_token: &str,
|
|
stream_details: StreamDetails,
|
|
status: StatusCode,
|
|
) -> axum::response::Response {
|
|
let StreamDetails { provider_handle, stream_info, .. } = stream_details;
|
|
app_state.connection_manager.release_managed_provider_handle(provider_handle);
|
|
app_state.active_users.release_unbound_session_reservation(username, session_token, None, false).await;
|
|
let mut response = status.into_response();
|
|
if let Some((headers, _, _, None)) = stream_info {
|
|
for (name, value) in headers {
|
|
if let (Ok(name), Ok(value)) = (HeaderName::try_from(name), HeaderValue::try_from(value)) {
|
|
response.headers_mut().insert(name, value);
|
|
}
|
|
}
|
|
}
|
|
response
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn force_stream_response(
|
|
fingerprint: &Fingerprint,
|
|
app_state: &Arc<AppState>,
|
|
user_session: &UserSession,
|
|
mut stream_channel: StreamChannel,
|
|
ctx: ForceStreamRequestContext<'_>,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
response_mode: StreamResponseMode,
|
|
) -> axum::response::Response {
|
|
let hls_resource = response_mode == StreamResponseMode::HlsResource;
|
|
// Finite HLS resources stay pinned to the session account and never rebind or clean up
|
|
// other sockets, so they skip the session transition gate. Holding it across the
|
|
// capacity wait would serialize parallel audio and video rendition requests.
|
|
let _transition_guard = if hls_resource {
|
|
None
|
|
} else {
|
|
Some(app_state.active_users.acquire_playback_transition(&ctx.user.username, &user_session.token).await)
|
|
};
|
|
let stream_options = get_stream_options(&app_state.app_config, response_mode);
|
|
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 hls_resource {
|
|
Vec::new()
|
|
} else 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, &user_session.token, &cleanup_addrs).await;
|
|
}
|
|
|
|
// HLS child URLs remain bound to the manifest account before the first media byte.
|
|
// Other streams prefer the account after media starts or while its allocation is active,
|
|
// and can fall back to the lineup when that account is exhausted.
|
|
let preferred_provider = (hls_resource
|
|
|| item_type.is_live()
|
|
|| user_session.media_started.load(std::sync::atomic::Ordering::Acquire)
|
|
|| app_state.active_provider.should_reuse_playback_provider(&user_session.token, &user_session.provider))
|
|
.then_some(&user_session.provider);
|
|
// Child URLs and their signed tokens belong to the account that fetched the playlist.
|
|
let allow_forced_provider_fallback =
|
|
!hls_resource && (!item_type.requires_provider_affinity() || allows_provider_pool_failover(item_type));
|
|
// 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 session_identity = user_session.identity();
|
|
let current_session = hls_resource.then_some(CurrentSessionGuard {
|
|
username: &ctx.user.username,
|
|
token: &user_session.token,
|
|
identity: session_identity,
|
|
});
|
|
// Finite resources read their cookies after the capacity wait instead of from this snapshot.
|
|
let provider_session_headers = preferred_provider
|
|
.filter(|_| !hls_resource)
|
|
.and_then(|_| user_session.provider_session_headers_for(&user_session.stream_url));
|
|
let create_details = 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()),
|
|
provider_session_headers.as_deref(),
|
|
hls_resource || preferred_provider.is_some(),
|
|
grace_mode.map(|mode| matches!(mode, crate::api::model::GraceMode::Hold)),
|
|
None,
|
|
current_session,
|
|
None,
|
|
);
|
|
// The FORBIDDEN exits below leave the session reservation alone: they only fire once this
|
|
// session identity is gone, and after an account switch the reservation belongs to the new
|
|
// binding. Dropping the cancelled future frees any provider slot it held.
|
|
let details_result = if hls_resource {
|
|
tokio::select! {
|
|
result = create_details => result,
|
|
() = app_state.active_users.wait_for_playback_session_end(
|
|
&ctx.user.username, &user_session.token, session_identity,
|
|
) => return StatusCode::FORBIDDEN.into_response(),
|
|
}
|
|
} else {
|
|
create_details.await
|
|
};
|
|
let mut stream_details = match details_result {
|
|
Ok(stream_details) => stream_details,
|
|
Err(err) => {
|
|
if hls_resource
|
|
&& !app_state
|
|
.active_users
|
|
.playback_session_is_current(&ctx.user.username, &user_session.token, session_identity)
|
|
.await
|
|
{
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
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();
|
|
}
|
|
};
|
|
|
|
if hls_resource {
|
|
if !app_state
|
|
.active_users
|
|
.playback_session_is_current(&ctx.user.username, &user_session.token, session_identity)
|
|
.await
|
|
{
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
stream_details.session_registration = Some(tuliprox_session::PlaybackSessionRegistration {
|
|
identity: session_identity,
|
|
enforce_limits: app_state.app_config.config.load().user_access_control,
|
|
grace_admitted: user_session.permission == UserConnectionPermission::GracePeriod,
|
|
});
|
|
if let Some(status) = hls_resource_failure_status(&stream_details) {
|
|
return reject_hls_resource(app_state, &ctx.user.username, &user_session.token, stream_details, status)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let deferred_grace_hold_stream = stream_details.has_deferred_provider_open();
|
|
|
|
if stream_details.has_stream() || deferred_grace_hold_stream {
|
|
let selected_provider = stream_details.provider_name.clone();
|
|
let selected_request_url = stream_details.request_url.clone();
|
|
let selected_provider_headers = stream_details.provider_session_headers.clone();
|
|
let metering = prepare_stream_metering(
|
|
app_state,
|
|
user_session.stream_url.as_ref(),
|
|
false,
|
|
stream_details.stream.is_some(),
|
|
stream_details.has_deferred_provider_open(),
|
|
);
|
|
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_adaptive_playback_lease(
|
|
provider_name,
|
|
&user_session.token,
|
|
PlaybackKind::classify(item_type, extract_extension_from_url(user_session.stream_url.as_ref())),
|
|
ctx.session_reservation_ttl_secs,
|
|
);
|
|
}
|
|
}
|
|
if let Some(allocated_provider) = stream_details.provider_name.as_ref() {
|
|
if allocated_provider.as_ref() != user_session.provider.as_ref() {
|
|
let new_stream_url =
|
|
stream_details.request_url.as_deref().map_or_else(|| user_session.stream_url.clone(), Arc::from);
|
|
app_state
|
|
.active_users
|
|
.update_session_provider_binding(
|
|
&ctx.user.username,
|
|
&user_session.token,
|
|
Arc::clone(allocated_provider),
|
|
new_stream_url,
|
|
)
|
|
.await;
|
|
}
|
|
if allocated_provider.as_ref() != user_session.provider.as_ref()
|
|
|| !stream_details.provider_session_headers.is_empty()
|
|
{
|
|
let cookie_origin = provider_response
|
|
.as_ref()
|
|
.and_then(|(_, _, url, _)| url.as_ref())
|
|
.map_or(user_session.stream_url.as_ref(), Url::as_str);
|
|
let active_users = &app_state.active_users;
|
|
if hls_resource {
|
|
// Cookies from the previous account never enter a jar after an account switch.
|
|
active_users
|
|
.update_current_session_provider_response_headers_from(
|
|
&ctx.user.username,
|
|
&user_session.token,
|
|
session_identity,
|
|
&stream_details.provider_session_headers,
|
|
cookie_origin,
|
|
)
|
|
.await;
|
|
} else {
|
|
active_users
|
|
.update_session_provider_response_headers_from(
|
|
&ctx.user.username,
|
|
&user_session.token,
|
|
&stream_details.provider_session_headers,
|
|
cookie_origin,
|
|
)
|
|
.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 = match 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
|
|
{
|
|
Ok(stream) => stream,
|
|
Err(error) => {
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(&ctx.user.username, &user_session.token, None, false)
|
|
.await;
|
|
return stream_admission_rejected_response(error, &ctx.user.username);
|
|
}
|
|
};
|
|
|
|
if let Some(provider) = selected_provider.as_deref() {
|
|
let session_url = selected_request_url.as_deref().unwrap_or(user_session.stream_url.as_ref());
|
|
app_state
|
|
.active_users
|
|
.create_user_session(crate::api::model::CreateUserSessionParams {
|
|
user: ctx.user,
|
|
session_token: &user_session.token,
|
|
virtual_id: user_session.virtual_id,
|
|
provider,
|
|
stream_url: session_url,
|
|
addr: &fingerprint.addr,
|
|
connection_permission,
|
|
connection_kind: user_session.connection_kind,
|
|
socket_bound: user_session.socket_bound,
|
|
})
|
|
.await;
|
|
if !selected_provider_headers.is_empty() {
|
|
let cookie_origin =
|
|
provider_response.as_ref().and_then(|(_, _, url, _)| url.as_ref()).map_or(session_url, Url::as_str);
|
|
app_state
|
|
.active_users
|
|
.update_session_provider_response_headers_from(
|
|
&ctx.user.username,
|
|
&user_session.token,
|
|
&selected_provider_headers,
|
|
cookie_origin,
|
|
)
|
|
.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_managed_provider_handle(stream_details.provider_handle);
|
|
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()
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn stream_response(
|
|
fingerprint: &Fingerprint,
|
|
app_state: &Arc<AppState>,
|
|
session_token: &str,
|
|
request_class: Option<PlaybackRequestClass>,
|
|
stream_channel: StreamChannel,
|
|
stream_url: &str,
|
|
pinned_provider: Option<&Arc<str>>,
|
|
req_headers: &HeaderMap,
|
|
input: &Arc<ConfigInput>,
|
|
target: &Arc<ConfigTarget>,
|
|
user: &ProxyUserCredentials,
|
|
connection_permission: UserConnectionPermission,
|
|
connection_kind: crate::api::model::ConnectionKind,
|
|
allow_exhausted_shared_reconnect: bool,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
) -> axum::response::Response {
|
|
stream_response_with_provider_handle(
|
|
fingerprint,
|
|
app_state,
|
|
session_token,
|
|
request_class,
|
|
stream_channel,
|
|
stream_url,
|
|
pinned_provider,
|
|
req_headers,
|
|
input,
|
|
target,
|
|
user,
|
|
connection_permission,
|
|
connection_kind,
|
|
allow_exhausted_shared_reconnect,
|
|
grace_mode,
|
|
None,
|
|
)
|
|
.await
|
|
.into_response()
|
|
}
|
|
|
|
/// # Panics
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
|
pub(crate) async fn stream_response_with_provider_handle(
|
|
fingerprint: &Fingerprint,
|
|
app_state: &Arc<AppState>,
|
|
session_token: &str,
|
|
request_class: Option<PlaybackRequestClass>,
|
|
mut stream_channel: StreamChannel,
|
|
stream_url: &str,
|
|
pinned_provider: Option<&Arc<str>>,
|
|
req_headers: &HeaderMap,
|
|
input: &Arc<ConfigInput>,
|
|
target: &Arc<ConfigTarget>,
|
|
user: &ProxyUserCredentials,
|
|
connection_permission: UserConnectionPermission,
|
|
connection_kind: crate::api::model::ConnectionKind,
|
|
allow_exhausted_shared_reconnect: bool,
|
|
grace_mode: Option<crate::api::model::GraceMode>,
|
|
preacquired_provider_handle: Option<tuliprox_session::ManagedProviderHandle>,
|
|
) -> 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,
|
|
granted_grace_mode: grace_mode,
|
|
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,
|
|
activation.placeholder_transition_version,
|
|
)
|
|
.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,
|
|
activation.placeholder_transition_version,
|
|
)
|
|
.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;
|
|
if activation.admission.is_reentry_suppressed() {
|
|
return reentry_suppressed_response();
|
|
}
|
|
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, StreamResponseMode::Stream);
|
|
let session_state = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
|
|
let pinned_provider = pinned_provider.filter(|provider| {
|
|
item_type.is_live()
|
|
|| session_state
|
|
.as_ref()
|
|
.is_some_and(|session| session.media_started.load(std::sync::atomic::Ordering::Acquire))
|
|
|| app_state.active_provider.should_reuse_playback_provider(session_token, provider)
|
|
});
|
|
let provider_session_headers =
|
|
session_state.as_ref().and_then(|session| session.provider_session_headers_for(stream_url));
|
|
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() || allows_provider_pool_failover(item_type),
|
|
true,
|
|
VirtualId::new(stream_channel.virtual_id),
|
|
connection_priority_for_kind(user, connection_kind),
|
|
connection_kind,
|
|
false,
|
|
Some(session_token),
|
|
provider_session_headers.as_deref(),
|
|
pinned_provider.is_some(),
|
|
grace_mode.map(|m| matches!(m, crate::api::model::GraceMode::Hold)),
|
|
activation.grace_context.clone(),
|
|
None,
|
|
preacquired_provider_handle,
|
|
)
|
|
.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::<str>::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);
|
|
}
|
|
|
|
// 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 user_agent_stream_index = stream_details.user_agent_stream_index;
|
|
// Persist this before response construction because some item types skip the later
|
|
// create_user_session path, including placeholder sessions created by ensure_user_session_placeholder.
|
|
if let Some(stream_index) = user_agent_stream_index {
|
|
app_state
|
|
.active_users
|
|
.set_user_agent_stream_index_if_absent(&user.username, session_token, stream_index)
|
|
.await;
|
|
}
|
|
|
|
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 shared_subscriber_id =
|
|
tuliprox_core::model::SharedSubscriberId::from_stream_uid(app_state.connection_manager.next_stream_uid());
|
|
let mut pending_shared_cleanup = if is_stream_shared {
|
|
match SharedStreamManager::reserve_subscriber_cleanup(
|
|
&app_state.connection_manager,
|
|
shared_subscriber_id,
|
|
fingerprint.addr,
|
|
)
|
|
.await
|
|
{
|
|
Ok(cleanup) => Some(cleanup),
|
|
Err(reason) => {
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(
|
|
&user.username,
|
|
session_token,
|
|
activation.placeholder_transition_version,
|
|
activation.placeholder_transition_version.is_some(),
|
|
)
|
|
.await;
|
|
return stream_admission_rejected_response(reason.into(), &user.username);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let mut metering = prepare_stream_metering(
|
|
app_state,
|
|
stream_url,
|
|
is_stream_shared,
|
|
stream_details.stream.is_some(),
|
|
stream_details.has_deferred_provider_open(),
|
|
);
|
|
if let (Some(cleanup), Some(request_id)) = (
|
|
pending_shared_cleanup.as_mut(),
|
|
stream_details
|
|
.provider_handle
|
|
.as_ref()
|
|
.and_then(|managed| managed.handle())
|
|
.and_then(|handle| handle.playback_request_id),
|
|
) {
|
|
cleanup.set_provider_request_identity(session_token, request_id);
|
|
}
|
|
let provider_handle = if is_stream_shared && !stream_details.has_deferred_provider_open() {
|
|
// Transfer the ManagedProviderHandle to the shared-stream manager.
|
|
// Ownership stays with the managed type across all awaits; the shared-stream
|
|
// manager disarms it only once it holds the write lock and commits to a new
|
|
// shared origin. If the future is cancelled before that point the Drop guard
|
|
// releases the slot.
|
|
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;
|
|
}
|
|
if is_stream_shared {
|
|
stream_details.shared_subscriber_id =
|
|
pending_shared_cleanup.as_ref().map(tuliprox_session::PendingSharedSubscriberCleanup::capability);
|
|
}
|
|
// In the no-limits path there may be no placeholder yet. The body needs the
|
|
// session's media flag before its first byte; create that session now.
|
|
let created_media_session = if !is_stream_shared
|
|
&& !item_type.is_live()
|
|
&& item_type.requires_provider_affinity()
|
|
&& app_state.active_users.media_started_flag(&user.username, session_token).await.is_none()
|
|
{
|
|
if let Some(provider) = provider_name.as_deref() {
|
|
app_state
|
|
.active_users
|
|
.ensure_user_session_placeholder(crate::api::model::CreateUserSessionParams {
|
|
user,
|
|
session_token,
|
|
virtual_id,
|
|
provider,
|
|
stream_url: actual_request_url.as_ref(),
|
|
addr: &fingerprint.addr,
|
|
connection_permission,
|
|
connection_kind: Some(connection_kind),
|
|
socket_bound,
|
|
})
|
|
.await;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
let stream = match 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
|
|
{
|
|
Ok(stream) => stream,
|
|
Err(error) => {
|
|
if created_media_session {
|
|
app_state.active_users.terminate_session(&user.username, session_token).await;
|
|
} else {
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(
|
|
&user.username,
|
|
session_token,
|
|
activation.placeholder_transition_version,
|
|
activation.placeholder_transition_version.is_some(),
|
|
)
|
|
.await;
|
|
}
|
|
return stream_admission_rejected_response(error, &user.username);
|
|
}
|
|
};
|
|
let stream_resp = if is_stream_shared {
|
|
debug_if_enabled!(
|
|
"Streaming shared stream request from {}",
|
|
sanitize_sensitive_info(log_actual_request_url.as_ref())
|
|
);
|
|
let Some(pending_shared_cleanup) = pending_shared_cleanup else {
|
|
return stream_admission_rejected_response(StreamAdmissionError::RegistrationRejected, &user.username);
|
|
};
|
|
// 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, _cleanup_capability)) =
|
|
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_subscriber_id,
|
|
shared_headers,
|
|
stream_options.buffer_size,
|
|
provider_handle,
|
|
pending_shared_cleanup,
|
|
connection_priority_for_kind(user, connection_kind),
|
|
connection_kind,
|
|
)
|
|
.await
|
|
{
|
|
metering.commit_shared_registration();
|
|
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 let Some(stream_index) = user_agent_stream_index {
|
|
app_state
|
|
.active_users
|
|
.set_user_agent_stream_index_if_absent(&user.username, session_token, stream_index)
|
|
.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_adaptive_playback_lease(
|
|
&provider,
|
|
session_token,
|
|
PlaybackKind::classify(item_type, playback_extension),
|
|
reservation_ttl_secs,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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_managed_provider_handle(stream_details.provider_handle);
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(
|
|
&user.username,
|
|
session_token,
|
|
activation.placeholder_transition_version,
|
|
activation.placeholder_transition_version.is_some(),
|
|
)
|
|
.await;
|
|
if stream_details.custom_reason == Some(ProviderStreamCustomReason::ProviderExhausted) {
|
|
record_connect_failed_attempt(ConnectFailedAttempt {
|
|
app_state,
|
|
fingerprint,
|
|
user,
|
|
stream_channel,
|
|
provider_name: stream_details.provider_name.unwrap_or_else(|| input.name.clone()),
|
|
req_headers,
|
|
reason: ConnectFailureReason::ProviderConnectionsExhausted,
|
|
failure_stage: FailureStage::Admission,
|
|
});
|
|
return create_custom_video_stream_response(
|
|
&app_state.provider_stream_ctx(),
|
|
&fingerprint.addr,
|
|
CustomVideoStreamType::ProviderConnectionsExhausted,
|
|
)
|
|
.into_response();
|
|
}
|
|
no_custom_video_fallback_status(&app_state.app_config).into_response()
|
|
}
|
|
|
|
enum CatchupPayload {
|
|
Direct(BoxedProviderStream),
|
|
HlsManifest(Bytes),
|
|
}
|
|
|
|
struct DetectedCatchupHlsResponseParams<'a> {
|
|
app_state: &'a Arc<AppState>,
|
|
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,
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
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 playlist_kind = classify_hls_playlist(content);
|
|
if playlist_kind == HlsPlaylistKind::Invalid {
|
|
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),
|
|
origin_provider: Some(&provider),
|
|
playlist_kind: Some(playlist_kind),
|
|
},
|
|
);
|
|
|
|
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 let Some(stream_index) = stream_details.user_agent_stream_index {
|
|
app_state
|
|
.active_users
|
|
.set_user_agent_stream_index_if_absent(&user.username, &created_session_token, stream_index)
|
|
.await;
|
|
}
|
|
if !stream_details.provider_session_headers.is_empty() {
|
|
app_state
|
|
.active_users
|
|
.update_session_provider_response_headers_from(
|
|
&user.username,
|
|
&created_session_token,
|
|
&stream_details.provider_session_headers,
|
|
stream_details
|
|
.stream_info
|
|
.as_ref()
|
|
.and_then(|(_, _, url, _)| url.as_ref())
|
|
.map_or(request_url, Url::as_str),
|
|
)
|
|
.await;
|
|
}
|
|
app_state.active_provider.refresh_adaptive_playback_lease(
|
|
&provider,
|
|
&created_session_token,
|
|
PlaybackKind::Catchup,
|
|
get_catchup_session_ttl_secs(app_state),
|
|
);
|
|
let binding_tag = stream_details.provider_handle.as_ref().and_then(|handle| handle.handle()?.binding_tag);
|
|
app_state.connection_manager.release_managed_provider_handle(stream_details.provider_handle.take());
|
|
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;
|
|
|
|
if playlist_kind == HlsPlaylistKind::Media && hls_media_playlist_wrap_enabled(app_state, target, user) {
|
|
let master = wrap_media_playlist(
|
|
HlsMediaPlaylistWrap {
|
|
app_state,
|
|
user,
|
|
base_url: &base_url,
|
|
target_id: target.id,
|
|
input,
|
|
virtual_id: virtual_id.get(),
|
|
session_token: &created_session_token,
|
|
// The canonical catch-up URL, not the provider-resolved one, so a refresh can
|
|
// resolve it to whichever account is selected then.
|
|
sealed_url: fallback_stream_url,
|
|
provider: &provider,
|
|
binding_tag,
|
|
known_bitrate_bps: None,
|
|
stream_ref: None,
|
|
},
|
|
rewritten,
|
|
)
|
|
.await;
|
|
return catchup_hls_manifest_response(master);
|
|
}
|
|
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<AppState>,
|
|
stream_details: &mut StreamDetails,
|
|
username: &str,
|
|
session_token: &str,
|
|
) {
|
|
app_state.connection_manager.release_managed_provider_handle(stream_details.provider_handle.take());
|
|
app_state.active_users.terminate_session(username, session_token).await;
|
|
app_state.active_provider.clear_provider_reservation(session_token);
|
|
}
|
|
|
|
async fn probe_catchup_payload(stream: BoxedProviderStream, deadline: Duration) -> Result<CatchupPayload, StreamError> {
|
|
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<CatchupPayload, StreamError> {
|
|
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<AppState>) -> 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<AppState>) -> 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)
|
|
}
|
|
|
|
fn prepare_stream_metering(
|
|
app_state: &Arc<AppState>,
|
|
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, pending_registration) = app_state
|
|
.shared_stream_manager
|
|
.reserve_meter_uid(stream_url, || app_state.connection_manager.next_stream_uid());
|
|
return StreamMeteringConfig {
|
|
meter_uid,
|
|
meter_stream: has_stream || has_deferred_provider_open,
|
|
pending_shared_registration: pending_registration,
|
|
};
|
|
} 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, pending_shared_registration: None };
|
|
}
|
|
|
|
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<AppState>,
|
|
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<AppState>) -> 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<AppState>, session_owner: &str, cleanup_addrs: &[SocketAddr]) {
|
|
app_state.active_provider.release_playback_connections_await(session_owner, cleanup_addrs).await;
|
|
}
|
|
|
|
pub(crate) fn get_catchup_session_ttl_secs(app_state: &Arc<AppState>) -> u64 {
|
|
get_stream_config_u64(app_state, |stream| stream.catchup_session_ttl_secs, default_catchup_session_ttl_secs())
|
|
}
|
|
|
|
/// Reconnect window of an HLS playback lease: archive playback keeps the catchup window.
|
|
pub(crate) fn get_hls_playback_ttl_secs(app_state: &Arc<AppState>, kind: PlaybackKind) -> u64 {
|
|
if kind == PlaybackKind::Catchup {
|
|
get_catchup_session_ttl_secs(app_state)
|
|
} else {
|
|
get_hls_session_ttl_secs(app_state)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn get_session_reservation_ttl_secs(app_state: &Arc<AppState>, 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<AppState>,
|
|
_item_type: PlaylistItemType,
|
|
) -> bool {
|
|
!matches!(
|
|
stream_details.stream_info.as_ref(),
|
|
Some((_, _, _, Some(cv))) if *cv != CustomVideoStreamType::Provisioning
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
|
async fn try_shared_stream_response_if_any(
|
|
app_state: &Arc<AppState>,
|
|
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,
|
|
placeholder_transition_version: Option<u64>,
|
|
) -> Option<impl IntoResponse> {
|
|
let subscriber_id =
|
|
tuliprox_core::model::SharedSubscriberId::from_stream_uid(app_state.connection_manager.next_stream_uid());
|
|
let shared_subscription = 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,
|
|
subscriber_id,
|
|
connection_priority_for_kind(user, connection_kind),
|
|
connection_kind,
|
|
)
|
|
.await;
|
|
let (stream, provider, cleanup_capability) = match shared_subscription {
|
|
Ok(Some(subscription)) => subscription,
|
|
Ok(None) => return None,
|
|
Err(reason) => {
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(
|
|
&user.username,
|
|
session_token,
|
|
placeholder_transition_version,
|
|
placeholder_transition_version.is_some(),
|
|
)
|
|
.await;
|
|
return Some(stream_admission_rejected_response(reason.into(), &user.username));
|
|
}
|
|
};
|
|
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.shared_subscriber_id = Some(cleanup_capability);
|
|
|
|
stream_details.provider_name = provider;
|
|
let socket_bound =
|
|
is_socket_bound_playback_session(stream_channel.item_type, extract_extension_from_url(stream_url));
|
|
// A shared origin may have no provider allocation (e.g. a local/unknown owner), in
|
|
// which case `provider` is `None`. The subscriber must still be tracked as a user
|
|
// session, so fall back to a stable placeholder name rather than skipping session
|
|
// creation; `is_over_limit` on an unknown provider is a no-op.
|
|
let provider_name = stream_details.provider_name.as_deref().unwrap_or("shared");
|
|
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);
|
|
// Joining an existing origin reuses its meter identity. When metrics are disabled
|
|
// no meter exists to reserve; reserving one here would leave an ownerless entry that
|
|
// teardown cannot match and remove.
|
|
let (meter_uid, pending_registration) = if is_stream_metrics_enabled(app_state) {
|
|
app_state
|
|
.shared_stream_manager
|
|
.reserve_meter_uid(stream_url, || app_state.connection_manager.next_stream_uid())
|
|
} else {
|
|
(0, None)
|
|
};
|
|
stream_channel.shared_stream_id = Some(u64::from(meter_uid));
|
|
// The pending registration is carried through admission so its drop guard rolls
|
|
// the reservation back if the client stream is never created.
|
|
let mut metering =
|
|
StreamMeteringConfig { meter_uid, meter_stream: false, pending_shared_registration: pending_registration };
|
|
let stream = match 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
|
|
{
|
|
Ok(stream) => {
|
|
metering.commit_shared_registration();
|
|
stream.boxed()
|
|
}
|
|
Err(error) => {
|
|
app_state
|
|
.active_users
|
|
.release_unbound_session_reservation(
|
|
&user.username,
|
|
session_token,
|
|
placeholder_transition_version,
|
|
placeholder_transition_version.is_some(),
|
|
)
|
|
.await;
|
|
return Some(stream_admission_rejected_response(error, &user.username));
|
|
}
|
|
};
|
|
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<AppState>,
|
|
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<PlaybackRequestClass>,
|
|
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::<Vec<_>>())
|
|
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,
|
|
granted_grace_mode: None,
|
|
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;
|
|
if activation.admission.is_reentry_suppressed() {
|
|
return reentry_suppressed_response();
|
|
}
|
|
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);
|
|
let stream = match 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
|
|
{
|
|
Ok(stream) => stream,
|
|
Err(error) => return stream_admission_rejected_response(error, &user.username),
|
|
};
|
|
|
|
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<String>,
|
|
cache: &Arc<ArcSwapOption<RwLock<LRUResourceCache>>>,
|
|
) -> Arc<dyn Fn(usize) + Send + Sync> {
|
|
let resource_url = String::from(res_url);
|
|
let cache = Arc::clone(cache);
|
|
let add_cache_content: Arc<dyn Fn(usize) + Send + Sync> = 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<String> {
|
|
headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok()) // Option<&str>
|
|
.map(ToString::to_string) // Option<String>
|
|
.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<bool> {
|
|
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::<BY_HANDLE_FILE_INFORMATION>() };
|
|
// 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<AppState>,
|
|
cache_key: Option<&str>,
|
|
fetch_policy: ResourceFetchPolicy,
|
|
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)
|
|
&& (fetch_policy == ResourceFetchPolicy::Public
|
|
|| matches!(
|
|
*key,
|
|
header::CONTENT_TYPE
|
|
| header::CONTENT_LENGTH
|
|
| header::CONTENT_ENCODING
|
|
| header::CONTENT_RANGE
|
|
| header::ACCEPT_RANGES
|
|
| header::ETAG
|
|
| header::LAST_MODIFIED
|
|
| header::CACHE_CONTROL
|
|
))
|
|
{
|
|
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 {
|
|
if let Some(cache_key) = cache_key {
|
|
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(cache_key, 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(cache_key, 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)))
|
|
}
|
|
|
|
/// Upper bound of redirect hops a resource route follows. Every hop is classified, so the number of
|
|
/// requests a provider or EPG entry can trigger through one resource link stays bounded.
|
|
const RESOURCE_REDIRECT_LIMIT: u8 = 5;
|
|
|
|
/// Whether outbound requests of this configuration may leave through a proxy, including one provided by
|
|
/// the environment, because the public resource client honours both.
|
|
fn proxy_in_use(app_state: &Arc<AppState>) -> bool {
|
|
app_state.app_config.config.load().proxy.is_some() || crate::model::proxy_env_present()
|
|
}
|
|
|
|
async fn fetch_resource_with_retry(
|
|
app_state: &Arc<AppState>,
|
|
url: &Url,
|
|
fetch_policy: ResourceFetchPolicy,
|
|
resource_url: &str,
|
|
req_headers: &HashMap<String, Vec<u8>>,
|
|
input: Option<&ConfigInput>,
|
|
) -> Option<axum::response::Response> {
|
|
let cache_key = fetch_policy.cache_key(resource_url);
|
|
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 mut current_url = url.clone();
|
|
let mut current_headers = req_headers.clone();
|
|
let mut current_input = input;
|
|
let mut method = input.map_or(InputFetchMethod::GET, |i| i.method);
|
|
|
|
for redirects in 0..=RESOURCE_REDIRECT_LIMIT {
|
|
// Every hop is routed on its own, so a redirect cannot move a request to an egress the hop
|
|
// itself would not have used.
|
|
let hop = classify_resource_hop(current_url.as_str(), proxy_in_use(app_state)).await;
|
|
if hop == ResourceHop::Blocked {
|
|
debug!("Refused resource destination local to this host: {}", sanitize_sensitive_info(resource_url));
|
|
return None;
|
|
}
|
|
let use_proxy_aware_client = hop == ResourceHop::ViaProxy;
|
|
let provider_config = current_input.and_then(|i| i.get_resolve_provider(current_url.as_str()));
|
|
let response = match send_with_retry_and_provider(
|
|
&app_state.app_config,
|
|
¤t_url,
|
|
provider_config.as_ref(),
|
|
// Hand redirects back instead of retrying them: the loop below follows them itself, so that
|
|
// every hop is classified and no client-side redirect policy decides where the request ends.
|
|
true,
|
|
|resolved_url| {
|
|
let http_client = if use_proxy_aware_client {
|
|
app_state.http_clients.resource_public_no_redirect.load()
|
|
} else {
|
|
app_state.http_clients.resource_no_redirect.load()
|
|
};
|
|
request::get_client_request(
|
|
&http_client,
|
|
method,
|
|
current_input.map(|i| &i.headers),
|
|
resolved_url,
|
|
Some(¤t_headers),
|
|
disabled_headers.as_ref(),
|
|
default_user_agent.as_deref(),
|
|
)
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(err) => {
|
|
debug!(
|
|
"Resource fetch failed for {}: {}",
|
|
sanitize_sensitive_info(resource_url),
|
|
sanitize_sensitive_info(&err.to_string())
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let status = response.status();
|
|
if status.is_redirection() {
|
|
if redirects == RESOURCE_REDIRECT_LIMIT {
|
|
debug!("Resource redirect limit reached for {}", sanitize_sensitive_info(resource_url));
|
|
return None;
|
|
}
|
|
let next_url = response
|
|
.headers()
|
|
.get(header::LOCATION)
|
|
.and_then(|location| location.to_str().ok())
|
|
.and_then(|location| response.url().join(location).ok());
|
|
let Some(next_url) = next_url else {
|
|
debug!("Resource redirect has no usable location for {}", sanitize_sensitive_info(resource_url));
|
|
return None;
|
|
};
|
|
let same_origin = response.url().scheme() == next_url.scheme()
|
|
&& response.url().host_str() == next_url.host_str()
|
|
&& response.url().port_or_known_default() == next_url.port_or_known_default();
|
|
if !same_origin {
|
|
current_headers.retain(|key, _| request::is_safe_cross_origin_redirect_header(key));
|
|
current_input = None;
|
|
}
|
|
if !matches!(status, StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT) {
|
|
method = InputFetchMethod::GET;
|
|
}
|
|
current_url = next_url;
|
|
continue;
|
|
}
|
|
|
|
if status.is_success() {
|
|
return Some(
|
|
build_resource_stream_response(app_state, cache_key, fetch_policy, resource_url, response).await,
|
|
);
|
|
}
|
|
|
|
debug_if_enabled!("Failed to open resource got status {status} for {}", sanitize_sensitive_info(resource_url));
|
|
if !fetch_policy.relays_upstream_response() {
|
|
return Some(status.into_response());
|
|
}
|
|
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));
|
|
return Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream))));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// How the client-visible answer of a resource route is shaped.
|
|
///
|
|
/// The variant mirrors the classification of the destination the route serves: a [`Public`] destination
|
|
/// may be handed to the client as it was fetched, a [`NonPublic`] one is relayed with a sanitized
|
|
/// answer. Which client performs a hop is decided per hop, not by this policy.
|
|
///
|
|
/// [`Public`]: ResourceFetchPolicy::Public
|
|
/// [`NonPublic`]: ResourceFetchPolicy::NonPublic
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ResourceFetchPolicy {
|
|
Public,
|
|
NonPublic,
|
|
}
|
|
|
|
pub fn resource_input_for_url<'a>(input: Option<&'a ConfigInput>, resource_url: &str) -> Option<&'a ConfigInput> {
|
|
let input = input?;
|
|
let source = Url::parse(&input.url).ok()?;
|
|
let resource = Url::parse(resource_url).ok()?;
|
|
(source.scheme() == resource.scheme()
|
|
&& source.host_str() == resource.host_str()
|
|
&& source.port_or_known_default() == resource.port_or_known_default())
|
|
.then_some(input)
|
|
}
|
|
|
|
pub async fn resource_redirect_or_proxy(
|
|
app_state: &Arc<AppState>,
|
|
resource_url: &str,
|
|
req_headers: &HeaderMap,
|
|
input: Option<&ConfigInput>,
|
|
) -> axum::response::Response {
|
|
let Some(resource_url) = shared::model::persisted_resource_url(resource_url) else {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
};
|
|
let resource_url = resource_url.as_ref();
|
|
if resource_url.starts_with("/api/v1/library/thumbnail/") {
|
|
return redirect(resource_url).into_response();
|
|
}
|
|
if resource_url.starts_with("media-server://image/") {
|
|
return resource_response(app_state, ResourceFetchPolicy::NonPublic, resource_url, req_headers, None)
|
|
.await
|
|
.into_response();
|
|
}
|
|
let Ok(url) = Url::parse(resource_url) else {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
};
|
|
if !matches!(url.scheme(), "http" | "https") {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
}
|
|
let Some(host) = url.host_str() else {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
};
|
|
if classify_resource_destination(host).await == ResourceDestination::Public {
|
|
redirect(resource_url).into_response()
|
|
} else {
|
|
resource_proxy_response(app_state, resource_url, req_headers, resource_input_for_url(input, resource_url)).await
|
|
}
|
|
}
|
|
|
|
pub async fn resource_proxy_response(
|
|
app_state: &Arc<AppState>,
|
|
resource_url: &str,
|
|
req_headers: &HeaderMap,
|
|
input: Option<&ConfigInput>,
|
|
) -> axum::response::Response {
|
|
let Some(resource_url) = shared::model::persisted_resource_url(resource_url) else {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
};
|
|
let resource_url = resource_url.as_ref();
|
|
let input = resource_input_for_url(input, resource_url);
|
|
match classify_output_resource_url(resource_url).await {
|
|
ResourceOutputPolicy::Direct if resource_url.starts_with("/api/v1/library/thumbnail/") => {
|
|
redirect(resource_url).into_response()
|
|
}
|
|
ResourceOutputPolicy::Direct => {
|
|
resource_response(app_state, ResourceFetchPolicy::Public, resource_url, req_headers, input)
|
|
.await
|
|
.into_response()
|
|
}
|
|
ResourceOutputPolicy::Proxy => {
|
|
resource_response(app_state, ResourceFetchPolicy::NonPublic, resource_url, req_headers, input)
|
|
.await
|
|
.into_response()
|
|
}
|
|
ResourceOutputPolicy::Blocked => StatusCode::FORBIDDEN.into_response(),
|
|
}
|
|
}
|
|
|
|
impl ResourceFetchPolicy {
|
|
const fn cache_key(self, resource_url: &str) -> Option<&str> {
|
|
match self {
|
|
Self::Public => Some(resource_url),
|
|
Self::NonPublic => None,
|
|
}
|
|
}
|
|
|
|
/// Whether an upstream response may be relayed to the client.
|
|
///
|
|
/// A public destination was chosen by the user's own configuration, so what it answered is what
|
|
/// the client asked for. A resource proxy whose destination is deliberately hidden from the client
|
|
/// must not become a reader for that destination: status, headers and body of an upstream error can
|
|
/// disclose more about the internal service than the link itself.
|
|
const fn relays_upstream_response(self) -> bool { matches!(self, Self::Public) }
|
|
}
|
|
|
|
/// Callers must pass an already-decoded resource URL (not a `resource://v1/` locator).
|
|
/// [`resource_redirect_or_proxy`] and [`resource_proxy_response`] handle the decode before
|
|
/// dispatching here; XMLTV and Web-UI endpoints pass URLs from authenticated tokens.
|
|
pub async fn resource_response(
|
|
app_state: &Arc<AppState>,
|
|
fetch_policy: ResourceFetchPolicy,
|
|
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(request::is_safe_cross_origin_redirect_header));
|
|
let req_headers = get_headers_from_request(req_headers, &filter);
|
|
let cache_key = fetch_policy.cache_key(resource_url);
|
|
if let (Some(cache_key), Some(cache)) = (cache_key, app_state.cache.load().as_ref()) {
|
|
let cache_hit = {
|
|
let mut guard = cache.write().await;
|
|
guard.get_content(cache_key)
|
|
};
|
|
|
|
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) {
|
|
// A resource URL is chosen by playlist or EPG content, so the destination is never fetched
|
|
// blindly: an address local to this host (loopback, link-local, cloud metadata) would turn
|
|
// the proxy into a reader for the proxy host itself. Private network destinations stay
|
|
// allowed, because self-hosted services are the reason this route exists.
|
|
let Some(host) = url.host_str() else {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
};
|
|
if classify_resource_destination(host).await == ResourceDestination::Blocked {
|
|
debug!("Refused resource destination local to this host: {}", sanitize_sensitive_info(resource_url));
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
if let Some(resp) =
|
|
fetch_resource_with_retry(app_state, &url, fetch_policy, 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<AppState>,
|
|
resource_url: &str,
|
|
) -> Result<Response<Body>, 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_clients.default.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<str> {
|
|
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<AppState>) -> Option<String> {
|
|
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<T: Serialize>(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<T: Serialize>(data: &T) -> impl IntoResponse + Send {
|
|
(StatusCode::OK, axum::Json(data)).into_response()
|
|
}
|
|
|
|
pub fn json_or_bin_response<T: Serialize>(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<P>(
|
|
accept: Option<&str>,
|
|
data: Box<dyn Iterator<Item = P> + 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<P, S>(accept: Option<&str>, data: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = P> + 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<P, S, E>(accept: Option<&str>, data: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = Result<P, E>> + 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 and seekable playback use the stable client fingerprint so follow-up
|
|
// requests on new sockets reuse the same logical session. Plain live playback
|
|
// remains socket-bound so a separate connection creates a separate session.
|
|
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,
|
|
archive_discriminator: &str,
|
|
) -> String {
|
|
concat_string!(
|
|
"catchup|",
|
|
&fingerprint.key,
|
|
"|",
|
|
username,
|
|
"|",
|
|
&virtual_id.to_string(),
|
|
"|",
|
|
archive_discriminator.trim_matches('/')
|
|
)
|
|
}
|
|
|
|
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<SocketAddr> {
|
|
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<P>(iter: Box<dyn Iterator<Item = P> + 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, Infallible>(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<P>(iter: Box<dyn Iterator<Item = P> + 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, Infallible>(Bytes::from(buf)), iter)),
|
|
Err(err) => {
|
|
warn!("CBOR serialization error in stream: {err}");
|
|
Some((Ok::<Bytes, Infallible>(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<P, S>(stream: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = P> + 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, Infallible>(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<P, S, E>(stream: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = Result<P, E>> + 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<P, S>(stream: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = P> + 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, Infallible>(Bytes::from(buf)), stream)),
|
|
Err(err) => {
|
|
warn!("CBOR serialization error in stream: {err}");
|
|
Some((Ok::<Bytes, Infallible>(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<P, S, E>(stream: S) -> axum::response::Response
|
|
where
|
|
P: serde::Serialize + Send + 'static,
|
|
S: Stream<Item = Result<P, E>> + 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<S, E>(stream: S) -> impl Stream<Item = Result<Bytes, E>>
|
|
where
|
|
S: Stream<Item = Result<Bytes, E>> + 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<AppState>) -> 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,
|
|
}
|
|
}
|
|
|
|
/// The user internal recording playback runs as. Its server resolves to the
|
|
/// local API listener (`AppConfig::get_user_server_info`), and its HLS URLs
|
|
/// carry this identity to every follow-up request.
|
|
pub fn create_recording_proxy_user(app_state: &Arc<AppState>) -> ProxyUserCredentials {
|
|
use base64::Engine;
|
|
use sha2::Digest;
|
|
let mut user = create_api_proxy_user(app_state);
|
|
user.username = crate::model::RECORDING_PROXY_USERNAME.to_string();
|
|
let digest = sha2::Sha256::new()
|
|
.chain_update(b"tuliprox/internal-recording-user")
|
|
.chain_update(app_state.app_config.access_token_secret)
|
|
.finalize();
|
|
user.password = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest[..]);
|
|
user.server = None;
|
|
user
|
|
}
|
|
|
|
/// Recording headers configure the upstream capture. They are layered under
|
|
/// the input's own headers, which keep precedence, and above the worker's
|
|
/// request headers. The worker's `User-Agent` stays on the local request so
|
|
/// the stream remains identifiable as a recording.
|
|
pub(crate) fn with_recording_headers(app_config: &AppConfig, input: Arc<ConfigInput>) -> Arc<ConfigInput> {
|
|
let config = app_config.config.load();
|
|
let Some(recording) = config.recording() else { return input };
|
|
let recording_headers = recording.origin_headers(&config);
|
|
if recording_headers.keys().all(|name| input.headers.keys().any(|key| key.eq_ignore_ascii_case(name.as_str()))) {
|
|
return input;
|
|
}
|
|
let mut merged = input.as_ref().clone();
|
|
for (name, value) in recording_headers {
|
|
let Ok(value) = value.to_str() else { continue };
|
|
if !merged.headers.keys().any(|key| key.eq_ignore_ascii_case(name.as_str())) {
|
|
merged.headers.insert(name.as_str().to_string(), value.to_string());
|
|
}
|
|
}
|
|
Arc::new(merged)
|
|
}
|
|
|
|
/// The input as seen by `user`: recording playback adds the recording headers.
|
|
pub(crate) fn input_for_user(
|
|
app_config: &AppConfig,
|
|
user: &ProxyUserCredentials,
|
|
input: Arc<ConfigInput>,
|
|
) -> Arc<ConfigInput> {
|
|
if user.is_recording_proxy_user() {
|
|
with_recording_headers(app_config, input)
|
|
} else {
|
|
input
|
|
}
|
|
}
|
|
|
|
pub fn empty_json_response_as_object() -> axum::http::Result<axum::response::Response> {
|
|
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> {
|
|
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;
|