mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
@@ -711,6 +711,7 @@ mod tests {
|
||||
qos_aggregation: CancellationToken::new(),
|
||||
recordings: CancellationToken::new(),
|
||||
hls_cache: CancellationToken::new(),
|
||||
channel_groups: tokio_util::sync::CancellationToken::new(),
|
||||
};
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
|
||||
@@ -738,6 +739,7 @@ mod tests {
|
||||
auth: crate::api::model::AuthState::for_tests(),
|
||||
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
|
||||
maintenance: crate::api::model::create_test_maintenance(""),
|
||||
member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -155,10 +155,16 @@ pub(in crate::api::endpoints::hls_api) fn spawn_hls_runtime_bandwidth_persistenc
|
||||
let app_config = Arc::clone(&app_state.app_config);
|
||||
let hls_proxy = Arc::clone(&app_state.hls.proxy);
|
||||
let session = Arc::clone(session);
|
||||
let member_health = Arc::clone(&app_state.member_health);
|
||||
|
||||
Some(tokio::spawn(async move {
|
||||
let outcome = match persist_input_live_bitrate_bps(&app_config, &input, &stream_ref, bitrate_bps).await {
|
||||
Ok(repository_outcome) => hls_bandwidth_persistence_outcome(repository_outcome, &proxy_session_id),
|
||||
Ok(repository_outcome) => {
|
||||
let kbps = bitrate_bps / 1000;
|
||||
let key = (Arc::clone(&input.name), Arc::from(stream_ref.as_ref()));
|
||||
member_health.patch_probe(&key, None, kbps);
|
||||
hls_bandwidth_persistence_outcome(repository_outcome, &proxy_session_id)
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"HLS runtime bandwidth persistence failed: proxy_session={} error={}",
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(in crate::api::endpoints::hls_api) async fn try_reserve_hls_entry_origin_acc
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(in crate::api::endpoints::hls_api) async fn try_reserve_hls_virtual_entry_origin_account_for_redirect(
|
||||
app_state: &Arc<AppState>,
|
||||
fingerprint: &Fingerprint,
|
||||
@@ -168,6 +168,40 @@ pub(in crate::api::endpoints::hls_api) async fn try_reserve_hls_virtual_entry_or
|
||||
};
|
||||
let session_owner = shared_hls_session_owner.as_deref().unwrap_or(session_token.as_str());
|
||||
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) = classified {
|
||||
let playback = crate::api::api_utils::group_playback_settings(app_state, target);
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
let req = crate::api::api_utils::GroupSelectionRequest {
|
||||
lease: Some(PlaybackLeaseRef::new(session_owner, PlaybackKind::LiveHls)),
|
||||
allow_provider_grace: playback.allow_provider_grace,
|
||||
bypass_maintenance: user.is_recording_proxy_user(),
|
||||
..crate::api::api_utils::GroupSelectionRequest::new(
|
||||
&target_name,
|
||||
&group,
|
||||
playback.start,
|
||||
&fingerprint.addr,
|
||||
&user.username,
|
||||
connection_priority_for_kind(user, connection_kind),
|
||||
connection_kind,
|
||||
)
|
||||
};
|
||||
let Ok(resolved) = crate::api::api_utils::resolve_hls_group_entry(app_state, Arc::clone(&group), req).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut acquired_handle = resolved.acquired.handle;
|
||||
let provider_handle = acquired_handle.take();
|
||||
app_state.connection_manager.release_provider_handle(provider_handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect(
|
||||
app_state,
|
||||
fingerprint,
|
||||
|
||||
@@ -185,9 +185,33 @@ async fn hls_recreate_content_allowed(
|
||||
return Some(user_allows_entry_content(user, scope, &item));
|
||||
}
|
||||
}
|
||||
let item =
|
||||
m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await.ok()?;
|
||||
Some(user_allows_entry_content(user, HlsEntryOutputScope::ItemType(item.item_type), &item))
|
||||
if let Ok(item) = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await
|
||||
{
|
||||
return Some(user_allows_entry_content(user, HlsEntryOutputScope::ItemType(item.item_type), &item));
|
||||
}
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
shared::model::VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } = classified {
|
||||
if target.has_output(TargetType::Xtream) {
|
||||
let item = tuliprox_repository::target_stream_item::member_to_xtream_item(&group, index, target);
|
||||
let scope = if is_catchup {
|
||||
HlsEntryOutputScope::LiveCluster
|
||||
} else {
|
||||
HlsEntryOutputScope::ItemType(item.item_type)
|
||||
};
|
||||
return Some(user_allows_entry_content(user, scope, &item));
|
||||
}
|
||||
let item = tuliprox_repository::target_stream_item::member_to_m3u_item(&group, index, target);
|
||||
let scope =
|
||||
if is_catchup { HlsEntryOutputScope::LiveCluster } else { HlsEntryOutputScope::ItemType(item.item_type) };
|
||||
return Some(user_allows_entry_content(user, scope, &item));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recreates a missing user session from a sealed `Manifest(Entry)` token.
|
||||
@@ -195,7 +219,7 @@ async fn hls_recreate_content_allowed(
|
||||
/// With the wrapper the player refreshes only the variant URI, so after a session expiry the
|
||||
/// token route has to do what an entry refresh did before: identity, access, admission, source,
|
||||
/// session, fetch. The session keeps the sealed session token and therefore its playback owner.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(super) async fn recreate_hls_session_from_token(
|
||||
fingerprint: &Fingerprint,
|
||||
req_headers: &HeaderMap,
|
||||
@@ -211,9 +235,21 @@ pub(super) async fn recreate_hls_session_from_token(
|
||||
else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
if hls_cache_enabled_for_user(app_state, target, user)
|
||||
|| !hls_session_hint_matches_requester(fingerprint, &user.username, virtual_id, hint)
|
||||
{
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
shared::model::VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
let hint_matches = hls_session_hint_matches_requester(fingerprint, &user.username, virtual_id, hint)
|
||||
|| match &classified {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, .. } => {
|
||||
hls_session_hint_matches_requester(fingerprint, &user.username, group.group_vid.get(), hint)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if hls_cache_enabled_for_user(app_state, target, user) || !hint_matches {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
// Only an expired session comes back; an evicted, kicked or terminated one stays ended,
|
||||
|
||||
@@ -5,15 +5,16 @@ use super::{
|
||||
hls_custom_video_manifest_response, hls_entry_user_session_token, hls_media_playlist_wrap_enabled,
|
||||
hls_panel_provisioning_or_status_response, hls_playback_kind, hls_response, m3u_archive_epg_reference_ts,
|
||||
m3u_catchup_epg_reference_from_session_token, normalize_xtream_live_hls_url, release_prepared_hls_manifest_session,
|
||||
terminate_failed_hls_manifest_session, try_reserve_hls_entry_origin_account_for_redirect, wrap_media_playlist,
|
||||
HlsEntryStreamContext, HlsMediaPlaylistWrap, HlsOriginEntryUrl, HlsRequestStage,
|
||||
resolve_hls_origin_playlist_url, terminate_failed_hls_manifest_session,
|
||||
try_reserve_hls_entry_origin_account_for_redirect, wrap_media_playlist, HlsEntryStreamContext,
|
||||
HlsMediaPlaylistWrap, HlsOriginEntryUrl, HlsRequestStage,
|
||||
};
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::{
|
||||
acquire_exact_provider_handle, blocks_new_playback, connection_priority_for_kind,
|
||||
get_hls_playback_ttl_secs, is_running_playback, select_provider_stream_url, ExactProviderAcquire,
|
||||
HLS_MANIFEST_CAPACITY_WAIT,
|
||||
get_hls_playback_ttl_secs, group_playback_settings, is_running_playback, resolve_hls_group_entry,
|
||||
select_provider_stream_url, ExactProviderAcquire, GroupSelectionRequest, HLS_MANIFEST_CAPACITY_WAIT,
|
||||
},
|
||||
model::{AppState, CustomVideoStreamType, PlaybackLeaseRef, ProviderAllocation, UserSession},
|
||||
},
|
||||
@@ -40,6 +41,10 @@ use tuliprox_hls::api::{
|
||||
build_hls_origin_session_owner, build_proxy_session_id, extract_hls_provider_session_headers, HlsOriginSource,
|
||||
HlsSessionKey,
|
||||
};
|
||||
use tuliprox_session::{
|
||||
channel_group::{FailureClass, HalfOpenGuard},
|
||||
MemberBinding,
|
||||
};
|
||||
|
||||
pub(in crate::api::endpoints::hls_api) struct HlsCacheManifestOrigin<'a> {
|
||||
pub(in crate::api::endpoints::hls_api) raw_request_url: &'a str,
|
||||
@@ -88,9 +93,26 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
let entry_group = if matches!(stage, HlsRequestStage::Entry) {
|
||||
match tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => Some(group),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Only a fresh entry is gated. Variant refreshes, segments and entry re-polls of a running
|
||||
// session (unwrapped media playlists) belong to a running playback.
|
||||
// session (unwrapped media playlists) belong to a running playback. A group checks
|
||||
// maintenance per member during selection.
|
||||
if matches!(stage, HlsRequestStage::Entry)
|
||||
&& entry_group.is_none()
|
||||
&& !is_running_playback(user_session)
|
||||
&& blocks_new_playback(app_state, input, user, false)
|
||||
{
|
||||
@@ -120,7 +142,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
};
|
||||
// The wrapper seals the normalized entry URL, not the provider-resolved one, so every
|
||||
// refresh can resolve it to whichever account is selected then.
|
||||
let entry_url = url.clone();
|
||||
let mut entry_url = url.clone();
|
||||
// Recover archive context when callers (esp. Xtream timeshift) pass None but the
|
||||
// resolved provider URL / catchup session still carries Flussonic archive markers.
|
||||
let archive_reference = archive_reference.or_else(|| m3u_archive_epg_reference_ts(&url)).or_else(|| {
|
||||
@@ -149,6 +171,79 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
);
|
||||
|
||||
if hls_cache_enabled_for_user(app_state, target, user) {
|
||||
if let Some(group) = entry_group {
|
||||
// The shared origin belongs to the selected member, so viewers of the same member share
|
||||
// it. The cache path reserves its own provider account; the selection only picks.
|
||||
let playback = group_playback_settings(app_state, target);
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
let kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal);
|
||||
let req = GroupSelectionRequest {
|
||||
allow_provider_grace: playback.allow_provider_grace,
|
||||
bypass_maintenance: user.is_recording_proxy_user(),
|
||||
allow_preempt: false,
|
||||
..GroupSelectionRequest::new(
|
||||
&target_name,
|
||||
&group,
|
||||
playback.start,
|
||||
&fingerprint.addr,
|
||||
&user.username,
|
||||
connection_priority_for_kind(user, kind),
|
||||
kind,
|
||||
)
|
||||
};
|
||||
let Ok(resolved) = resolve_hls_group_entry(app_state, Arc::clone(&group), req).await else {
|
||||
return hls_panel_provisioning_or_status_response(
|
||||
app_state,
|
||||
user,
|
||||
input,
|
||||
virtual_id,
|
||||
original_hls_entry_path,
|
||||
server_info.path.as_deref(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let mut selection_handle = resolved.acquired.handle;
|
||||
app_state.connection_manager.release_provider_handle(selection_handle.take());
|
||||
let member_url = resolve_hls_origin_playlist_url(
|
||||
app_state,
|
||||
target,
|
||||
&resolved.input,
|
||||
resolved.member.member_vid.get(),
|
||||
resolved.stream_url.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| resolved.stream_url.to_string());
|
||||
let member_url =
|
||||
ensure_hls_manifest_extension(&normalize_xtream_live_hls_url(&member_url, &resolved.input));
|
||||
let member_origin = build_hls_origin_resolution(&resolved.input, &member_url);
|
||||
let member_source = build_hls_origin_source_for_playback(
|
||||
&resolved.input,
|
||||
resolved.member.input_stream_id.to_string(),
|
||||
archive_reference,
|
||||
Some(member_url.as_str()),
|
||||
);
|
||||
return create_hls_cache_entry_master_playlist_response(
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
member_source,
|
||||
virtual_id,
|
||||
user_session,
|
||||
stream_context.known_bitrate_bps(),
|
||||
session_token_hint,
|
||||
if archive_reference.is_some() {
|
||||
member_url.as_str()
|
||||
} else {
|
||||
member_origin.as_ref().map_or(member_url.as_str(), |origin| origin.session_entry_url.as_str())
|
||||
},
|
||||
&resolved.input,
|
||||
connection_permission,
|
||||
connection_kind,
|
||||
server_info.path.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let Some(origin_source) = hls_origin_source.clone() else {
|
||||
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
};
|
||||
@@ -175,8 +270,21 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
}
|
||||
|
||||
let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal);
|
||||
let mut bound_member_info: Option<(u32, Arc<ConfigInput>, String, Option<HalfOpenGuard>)> = None;
|
||||
|
||||
let (request_url, session_token, provider_handle, selected_provider_config) = if let Some(session) = user_session {
|
||||
let pinned_provider = if session.provider.is_empty() { &input.name } else { &session.provider };
|
||||
let bound_inp_opt = session.member_binding.as_ref().and_then(|binding| {
|
||||
app_state.app_config.get_input_by_name(&binding.input_name).map(|inp| (binding.member_vid, inp))
|
||||
});
|
||||
if let Some((member_vid, ref bound_inp)) = bound_inp_opt {
|
||||
bound_member_info = Some((member_vid, Arc::clone(bound_inp), String::new(), None));
|
||||
}
|
||||
let effective_lookup_input = bound_inp_opt.as_ref().map_or(input, |(_, inp)| inp.as_ref());
|
||||
let pinned_provider = if session.provider.is_empty() {
|
||||
effective_lookup_input.name.clone()
|
||||
} else {
|
||||
Arc::clone(&session.provider)
|
||||
};
|
||||
let pinned_kind = hls_playback_kind(archive_reference, &url, Some(session.token.as_str()));
|
||||
let session_kind =
|
||||
session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal);
|
||||
@@ -185,7 +293,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
let acquired = acquire_exact_provider_handle(
|
||||
app_state,
|
||||
&ExactProviderAcquire {
|
||||
provider: pinned_provider,
|
||||
provider: &pinned_provider,
|
||||
addr: &fingerprint.addr,
|
||||
allow_grace: false,
|
||||
priority: connection_priority_for_kind(user, session_kind),
|
||||
@@ -200,7 +308,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
} else {
|
||||
debug_if_enabled!(
|
||||
"HLS pinned provider {} unavailable for {}; aborting allocation to prevent mid-session migration",
|
||||
sanitize_sensitive_info(pinned_provider),
|
||||
sanitize_sensitive_info(&pinned_provider),
|
||||
sanitize_sensitive_info(&fingerprint.addr.to_string())
|
||||
);
|
||||
None
|
||||
@@ -216,7 +324,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
return hls_panel_provisioning_or_status_response(
|
||||
app_state,
|
||||
user,
|
||||
input,
|
||||
effective_lookup_input,
|
||||
virtual_id,
|
||||
original_hls_entry_path,
|
||||
server_info.path.as_deref(),
|
||||
@@ -250,7 +358,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
};
|
||||
let Some((_provider_name, stream_url)) = select_provider_stream_url(
|
||||
&url,
|
||||
input,
|
||||
effective_lookup_input,
|
||||
&selected_provider_config,
|
||||
accept_requested_stream_url,
|
||||
&app_state.app_config,
|
||||
@@ -277,7 +385,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
debug_if_enabled!(
|
||||
"HLS session {} changed while waiting for pinned provider {}",
|
||||
sanitize_sensitive_info(&session.token),
|
||||
sanitize_sensitive_info(pinned_provider)
|
||||
sanitize_sensitive_info(&pinned_provider)
|
||||
);
|
||||
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
@@ -291,7 +399,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
.create_user_session(crate::api::model::CreateUserSessionParams {
|
||||
user,
|
||||
session_token: &session.token,
|
||||
virtual_id,
|
||||
virtual_id: session.virtual_id,
|
||||
provider: &selected_provider_config.name,
|
||||
stream_url: &stream_url,
|
||||
addr: &fingerprint.addr,
|
||||
@@ -312,67 +420,199 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
None => (url, None, None, None),
|
||||
}
|
||||
} else {
|
||||
// Append/shift catchup must keep an m3u-catchup session token even when shared HLS
|
||||
// cache is off; otherwise rewritten segments register as LiveHls in the panel.
|
||||
let user_session_token = hls_entry_user_session_token(
|
||||
fingerprint,
|
||||
&user.username,
|
||||
virtual_id,
|
||||
session_token_hint,
|
||||
archive_reference,
|
||||
);
|
||||
let hls_session_owner = if hls_cache_enabled_for_user(app_state, target, user) {
|
||||
let session_key = HlsSessionKey::new(input.id, stream_context.stream_ref());
|
||||
let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret());
|
||||
Some(build_hls_origin_session_owner(&proxy_session_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_owner = hls_session_owner.as_deref().unwrap_or(user_session_token.as_str());
|
||||
// Archive playback keeps its own reconnect window semantics, so the lease must
|
||||
// be classified as catchup rather than plain live HLS.
|
||||
let playback_kind = hls_playback_kind(archive_reference, &url, Some(user_session_token.as_str()));
|
||||
let hls_session_ttl_secs = get_hls_playback_ttl_secs(app_state, playback_kind);
|
||||
let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect(
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
input,
|
||||
virtual_id,
|
||||
&url,
|
||||
&user_session_token,
|
||||
session_owner,
|
||||
playback_kind,
|
||||
hls_session_ttl_secs,
|
||||
connection_permission,
|
||||
fallback_connection_kind,
|
||||
true,
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return hls_panel_provisioning_or_status_response(
|
||||
.await;
|
||||
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) = classified {
|
||||
let user_session_token = hls_entry_user_session_token(
|
||||
fingerprint,
|
||||
&user.username,
|
||||
virtual_id,
|
||||
session_token_hint,
|
||||
archive_reference,
|
||||
);
|
||||
let playback_kind = hls_playback_kind(archive_reference, &url, Some(user_session_token.as_str()));
|
||||
let hls_session_ttl_secs = get_hls_playback_ttl_secs(app_state, playback_kind);
|
||||
|
||||
let playback = group_playback_settings(app_state, target);
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
let req = GroupSelectionRequest {
|
||||
lease: Some(PlaybackLeaseRef::new(user_session_token.as_str(), playback_kind)),
|
||||
allow_provider_grace: playback.allow_provider_grace,
|
||||
bypass_maintenance: user.is_recording_proxy_user(),
|
||||
..GroupSelectionRequest::new(
|
||||
&target_name,
|
||||
&group,
|
||||
playback.start,
|
||||
&fingerprint.addr,
|
||||
&user.username,
|
||||
connection_priority_for_kind(user, fallback_connection_kind),
|
||||
fallback_connection_kind,
|
||||
)
|
||||
};
|
||||
|
||||
let Ok(resolved) = resolve_hls_group_entry(app_state, Arc::clone(&group), req).await else {
|
||||
return hls_panel_provisioning_or_status_response(
|
||||
app_state,
|
||||
user,
|
||||
input,
|
||||
virtual_id,
|
||||
original_hls_entry_path,
|
||||
server_info.path.as_deref(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
let mut acquired_handle = resolved.acquired.handle;
|
||||
let provider_handle = acquired_handle.take();
|
||||
let selected_provider_config =
|
||||
provider_handle.as_ref().and_then(|handle| handle.allocation.get_provider_config());
|
||||
|
||||
let origin_member_url = resolve_hls_origin_playlist_url(
|
||||
app_state,
|
||||
target,
|
||||
&resolved.input,
|
||||
resolved.member.member_vid.get(),
|
||||
resolved.stream_url.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| resolved.stream_url.to_string());
|
||||
|
||||
let member_url = if let Some(cfg) = &selected_provider_config {
|
||||
select_provider_stream_url(&origin_member_url, &resolved.input, cfg, false, &app_state.app_config)
|
||||
.await
|
||||
.map_or_else(|| origin_member_url.clone(), |(_, u)| u)
|
||||
} else {
|
||||
origin_member_url.clone()
|
||||
};
|
||||
|
||||
let provider_name = selected_provider_config
|
||||
.as_ref()
|
||||
.map_or_else(|| Arc::clone(&resolved.input.name), |cfg| Arc::clone(&cfg.name));
|
||||
|
||||
let session_token = app_state
|
||||
.active_users
|
||||
.create_user_session(crate::api::model::CreateUserSessionParams {
|
||||
user,
|
||||
session_token: &user_session_token,
|
||||
virtual_id,
|
||||
provider: &provider_name,
|
||||
stream_url: &member_url,
|
||||
addr: &fingerprint.addr,
|
||||
connection_permission,
|
||||
connection_kind: Some(fallback_connection_kind),
|
||||
socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(),
|
||||
})
|
||||
.await;
|
||||
|
||||
app_state
|
||||
.active_users
|
||||
.bind_member(
|
||||
&user.username,
|
||||
&session_token,
|
||||
MemberBinding {
|
||||
group_vid: group.group_vid.get(),
|
||||
member_vid: resolved.member.member_vid.get(),
|
||||
member_index: resolved.acquired.index,
|
||||
input_name: Arc::clone(&resolved.member.input_name),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
app_state.active_provider.refresh_adaptive_playback_lease(
|
||||
&provider_name,
|
||||
&user_session_token,
|
||||
playback_kind,
|
||||
hls_session_ttl_secs,
|
||||
);
|
||||
|
||||
bound_member_info = Some((
|
||||
resolved.member.member_vid.get(),
|
||||
resolved.input,
|
||||
resolved.member.input_stream_id.to_string(),
|
||||
resolved.acquired.half_open,
|
||||
));
|
||||
|
||||
debug_if_enabled!(
|
||||
"API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} (group) member_vid={} provider={} stream_url={}",
|
||||
sanitize_sensitive_info(&user.username),
|
||||
resolved.member.member_vid.get(),
|
||||
selected_provider_config.as_ref().map_or("<unknown>", |provider| provider.name.as_ref()),
|
||||
sanitize_sensitive_info(&member_url)
|
||||
);
|
||||
|
||||
entry_url = member_url.clone();
|
||||
|
||||
(member_url, Some(session_token), provider_handle, selected_provider_config)
|
||||
} else {
|
||||
// Append/shift catchup must keep an m3u-catchup session token even when shared HLS
|
||||
// cache is off; otherwise rewritten segments register as LiveHls in the panel.
|
||||
let user_session_token = hls_entry_user_session_token(
|
||||
fingerprint,
|
||||
&user.username,
|
||||
virtual_id,
|
||||
session_token_hint,
|
||||
archive_reference,
|
||||
);
|
||||
let hls_session_owner = if hls_cache_enabled_for_user(app_state, target, user) {
|
||||
let session_key = HlsSessionKey::new(input.id, stream_context.stream_ref());
|
||||
let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret());
|
||||
Some(build_hls_origin_session_owner(&proxy_session_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_owner = hls_session_owner.as_deref().unwrap_or(user_session_token.as_str());
|
||||
// Archive playback keeps its own reconnect window semantics, so the lease must
|
||||
// be classified as catchup rather than plain live HLS.
|
||||
let playback_kind = hls_playback_kind(archive_reference, &url, Some(user_session_token.as_str()));
|
||||
let hls_session_ttl_secs = get_hls_playback_ttl_secs(app_state, playback_kind);
|
||||
let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect(
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
input,
|
||||
virtual_id,
|
||||
original_hls_entry_path,
|
||||
server_info.path.as_deref(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
&url,
|
||||
&user_session_token,
|
||||
session_owner,
|
||||
playback_kind,
|
||||
hls_session_ttl_secs,
|
||||
connection_permission,
|
||||
fallback_connection_kind,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
debug_if_enabled!(
|
||||
"API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}",
|
||||
sanitize_sensitive_info(&user.username),
|
||||
reservation.selected_provider_config.as_ref().map_or("<unknown>", |provider| provider.name.as_ref()),
|
||||
sanitize_sensitive_info(&reservation.request_url)
|
||||
);
|
||||
(
|
||||
reservation.request_url,
|
||||
Some(reservation.session_token),
|
||||
reservation.provider_handle,
|
||||
reservation.selected_provider_config,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return hls_panel_provisioning_or_status_response(
|
||||
app_state,
|
||||
user,
|
||||
input,
|
||||
virtual_id,
|
||||
original_hls_entry_path,
|
||||
server_info.path.as_deref(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
debug_if_enabled!(
|
||||
"API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}",
|
||||
sanitize_sensitive_info(&user.username),
|
||||
reservation.selected_provider_config.as_ref().map_or("<unknown>", |provider| provider.name.as_ref()),
|
||||
sanitize_sensitive_info(&reservation.request_url)
|
||||
);
|
||||
(
|
||||
reservation.request_url,
|
||||
Some(reservation.session_token),
|
||||
reservation.provider_handle,
|
||||
reservation.selected_provider_config,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// The session as created or reserved above; manifest response cookies may only enter this
|
||||
@@ -403,9 +643,16 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
}
|
||||
}
|
||||
|
||||
let (effective_virtual_id, effective_input, effective_stream_ref, half_open_guard) =
|
||||
if let Some((m_vid, ref m_input, ref m_sref, guard)) = bound_member_info {
|
||||
(m_vid, m_input.as_ref(), if m_sref.is_empty() { stream_ref.as_str() } else { m_sref.as_str() }, guard)
|
||||
} else {
|
||||
(virtual_id, input, stream_ref.as_str(), None)
|
||||
};
|
||||
|
||||
let user_agent_stream_index = crate::api::api_utils::resolve_stream_user_agent_index(
|
||||
app_state,
|
||||
input,
|
||||
effective_input,
|
||||
provider_handle.is_some(),
|
||||
&user.username,
|
||||
session_token.as_deref(),
|
||||
@@ -426,7 +673,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
// breaks forced same-account reuse on the next HLS/Catchup stream request.
|
||||
app_state.connection_manager.release_provider_handle(provider_handle);
|
||||
|
||||
let input_source = InputSource::from(input).with_url(request_url);
|
||||
let input_source = InputSource::from(effective_input).with_url(request_url);
|
||||
let download_result = match download_legacy_hls_manifest(app_state, &input_source, &headers).await {
|
||||
Ok((content, response_url, response_headers)) => match classify_hls_playlist(&content) {
|
||||
// A refresh keeps its session on a malformed body; the player retries the playlist.
|
||||
@@ -447,6 +694,9 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
};
|
||||
match download_result {
|
||||
Ok((content, response_url, response_headers, playlist_kind)) => {
|
||||
if let Some(guard) = half_open_guard {
|
||||
app_state.member_health.record_success_with(guard);
|
||||
}
|
||||
let encrypt_secret = app_state.get_encrypt_secret();
|
||||
let base_url = server_info.get_base_url();
|
||||
let rewrite_hls_props = RewriteHlsProps {
|
||||
@@ -455,8 +705,8 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
content: &content,
|
||||
hls_url: response_url,
|
||||
target_id: target.id,
|
||||
virtual_id,
|
||||
input_id: input.id,
|
||||
virtual_id: effective_virtual_id,
|
||||
input_id: effective_input.id,
|
||||
user_token: session_token.as_deref(),
|
||||
origin_provider: selected_provider_name.as_deref(),
|
||||
playlist_kind: Some(playlist_kind),
|
||||
@@ -489,14 +739,14 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
user,
|
||||
base_url: &base_url,
|
||||
target_id: target.id,
|
||||
input,
|
||||
virtual_id,
|
||||
input: effective_input,
|
||||
virtual_id: effective_virtual_id,
|
||||
session_token,
|
||||
sealed_url: &entry_url,
|
||||
provider,
|
||||
binding_tag: provider_binding_tag,
|
||||
known_bitrate_bps: stream_context.known_bitrate_bps(),
|
||||
stream_ref: Some(&stream_ref),
|
||||
stream_ref: Some(effective_stream_ref),
|
||||
},
|
||||
hls_content,
|
||||
)
|
||||
@@ -507,6 +757,13 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
hls_response(hls_content).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(guard) = half_open_guard {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
error!("Failed to download m3u8: {}", request::text_response_error_log_label(&err));
|
||||
if let Some(session_token) = session_token.as_deref() {
|
||||
terminate_failed_hls_manifest_session(
|
||||
@@ -544,8 +801,28 @@ pub(in crate::api::endpoints::hls_api) async fn get_stream_channel(
|
||||
}
|
||||
}
|
||||
let target_id = target.id;
|
||||
m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target)
|
||||
.await
|
||||
.ok()
|
||||
.map(|pli| pli.to_stream_channel(target_id))
|
||||
if let Ok(pli) = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await {
|
||||
return Some(pli.to_stream_channel(target_id));
|
||||
}
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
match classified {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
let mut channel = if target.has_output(TargetType::Xtream) {
|
||||
tuliprox_repository::target_stream_item::member_to_xtream_item(&group, index, target)
|
||||
.to_stream_channel(target_id)
|
||||
} else {
|
||||
tuliprox_repository::target_stream_item::member_to_m3u_item(&group, index, target)
|
||||
.to_stream_channel(target_id)
|
||||
};
|
||||
channel.group_virtual_id = Some(group.group_vid.get());
|
||||
Some(channel)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ pub(in crate::api::endpoints::hls_api) fn fallback_hls_cache_stream_channel(
|
||||
StreamChannel {
|
||||
target_id,
|
||||
virtual_id,
|
||||
group_virtual_id: None,
|
||||
provider_id: 0,
|
||||
input_name: Arc::clone(&origin_source.input_name),
|
||||
item_type: PlaylistItemType::LiveHls,
|
||||
|
||||
@@ -31,7 +31,7 @@ use shared::{
|
||||
defaults::HLS_EXT,
|
||||
model::{
|
||||
ConnectFailureReason, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission,
|
||||
XtreamCluster,
|
||||
VirtualId, XtreamCluster,
|
||||
},
|
||||
utils::{generate_random_string, is_m3u_catchup_session_token, sanitize_sensitive_info, Internable},
|
||||
};
|
||||
@@ -58,19 +58,47 @@ pub(in crate::api) async fn resolve_hls_virtual_source_for_target(
|
||||
{
|
||||
let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?;
|
||||
(Arc::clone(&item.input_name), stream_context)
|
||||
} else {
|
||||
let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
} else if let Ok(item) =
|
||||
m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await
|
||||
{
|
||||
let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?;
|
||||
(Arc::clone(&item.input_name), stream_context)
|
||||
} else {
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } = classified {
|
||||
let item = tuliprox_repository::target_stream_item::member_to_xtream_item(&group, index, target);
|
||||
let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?;
|
||||
(Arc::clone(&item.input_name), stream_context)
|
||||
} else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let item = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
} else if let Ok(item) =
|
||||
m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await
|
||||
{
|
||||
let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?;
|
||||
(Arc::clone(&item.input_name), stream_context)
|
||||
} else {
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } = classified {
|
||||
let item = tuliprox_repository::target_stream_item::member_to_m3u_item(&group, index, target);
|
||||
let stream_context = hls_stream_context_or_unavailable(&item, virtual_id)?;
|
||||
(Arc::clone(&item.input_name), stream_context)
|
||||
} else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
};
|
||||
let input = app_state.app_config.get_input_by_name(&input_name).ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(HlsResolvedVirtualSource { input, stream_context })
|
||||
@@ -78,15 +106,30 @@ pub(in crate::api) async fn resolve_hls_virtual_source_for_target(
|
||||
|
||||
pub(in crate::api::endpoints::hls_api) async fn resolve_hls_origin_playlist_url(
|
||||
app_state: &Arc<AppState>,
|
||||
target: &Arc<ConfigTarget>,
|
||||
target: &ConfigTarget,
|
||||
input: &ConfigInput,
|
||||
virtual_id: u32,
|
||||
fallback_url: &str,
|
||||
) -> Result<String, StatusCode> {
|
||||
if input.input_type.is_xtream() && target.has_output(TargetType::Xtream) {
|
||||
let pli = xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let pli = if let Ok(item) =
|
||||
xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None).await
|
||||
{
|
||||
item
|
||||
} else {
|
||||
let classified = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
target,
|
||||
VirtualId::new(virtual_id),
|
||||
)
|
||||
.await;
|
||||
if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } = classified {
|
||||
tuliprox_repository::target_stream_item::member_to_xtream_item(&group, index, target)
|
||||
} else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
};
|
||||
let hls_extension = format!(".{HLS_EXT}");
|
||||
let (query_path, _) = get_query_path("", Some(&hls_extension), &pli, app_state);
|
||||
return get_xtream_player_api_stream_url(input, ApiStreamContext::Live, &query_path, &pli.url)
|
||||
@@ -133,6 +176,7 @@ pub(in crate::api::endpoints::hls_api) async fn resolve_stream_channel(
|
||||
None => StreamChannel {
|
||||
target_id: target.id,
|
||||
virtual_id,
|
||||
group_virtual_id: None,
|
||||
provider_id: 0,
|
||||
input_name: Arc::clone(&input.name),
|
||||
item_type: PlaylistItemType::LiveHls,
|
||||
@@ -417,7 +461,9 @@ pub(in crate::api::endpoints::hls_api) async fn hls_api_stream_resolved(
|
||||
let archive_reference = resolve_m3u_archive_reference(&hls_url, Some(session.token.as_str()))
|
||||
.or_else(|| m3u_archive_epg_reference_ts(session.stream_url.as_ref()));
|
||||
session.stream_url = hls_url.clone();
|
||||
if session.virtual_id == virtual_id {
|
||||
if session.virtual_id == virtual_id
|
||||
|| session.member_binding.as_ref().is_some_and(|b| b.member_vid == virtual_id)
|
||||
{
|
||||
app_state.connection_manager.touch_http_activity(&user.username, &session.token, &fingerprint.addr).await;
|
||||
} else {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
|
||||
@@ -138,3 +138,5 @@ mod terminal_tail_recovery_before_cutover;
|
||||
mod terminal_tail_terminal_cutover;
|
||||
mod terminal_tail_terminal_fallback;
|
||||
mod terminal_tail_terminal_preflight;
|
||||
|
||||
mod channel_group;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::api::api_utils::tests::group_playback_support::{first_segment_url, GroupPlaybackEnv};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn hls_entry_selects_member_with_tiers() {
|
||||
let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().build().await;
|
||||
let _blocker = env.hold("solis", 0);
|
||||
let manifest = env.get_hls_manifest(env.group_vid).await;
|
||||
assert_eq!(manifest.status(), StatusCode::OK);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert_eq!(env.bound_member_input("viewer").await, "korath");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn segment_routing_resolves_member_vid_to_member_input() {
|
||||
let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().with_qos("korath", 2, 95).build().await;
|
||||
let manifest = env.get_hls_manifest_text(env.group_vid).await;
|
||||
let segment_url = first_segment_url(&manifest);
|
||||
assert!(segment_url.contains(&env.member_vid("korath").to_string()));
|
||||
let segment = env.get(&segment_url).await;
|
||||
assert_eq!(segment.status(), StatusCode::OK);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert_eq!(env.bound_member_input("viewer").await, "korath");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_refresh_never_reselects_even_with_open_circuit() {
|
||||
let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().build().await;
|
||||
let first = env.get_hls_manifest_text(env.group_vid).await;
|
||||
let bound = env.bound_member_input("viewer").await;
|
||||
assert!(!bound.is_empty());
|
||||
env.open_circuit(&bound);
|
||||
let second = env.refresh_hls_manifest(&first).await;
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
assert_eq!(env.bound_member_input("viewer").await, bound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hls_cache_entry_uses_selected_member_input() {
|
||||
let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls_cache().with_qos("korath", 2, 95).build().await;
|
||||
let manifest = env.get_hls_manifest(env.group_vid).await;
|
||||
assert!(manifest.status().is_success() || manifest.status().is_redirection(), "status {}", manifest.status());
|
||||
let body = axum::body::to_bytes(manifest.into_body(), 1024 * 1024).await.expect("body");
|
||||
// The variant request makes the shared origin fetch its upstream playlist.
|
||||
let _variant = env.get(&first_segment_url(&String::from_utf8_lossy(&body))).await;
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
assert!(env.origin_hits("korath") > 0);
|
||||
}
|
||||
@@ -285,6 +285,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn test_app_state_with_hls_proxy_a
|
||||
auth: crate::api::model::AuthState::for_tests(),
|
||||
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
|
||||
maintenance: crate::api::model::create_test_maintenance(""),
|
||||
member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
resolve_initial_stalker_playback_url, resource_proxy_response, resource_redirect_or_proxy,
|
||||
separate_number_and_remainder, should_allow_exhausted_shared_reconnect,
|
||||
stream_response_with_provider_handle, try_option_bad_request, try_result_bad_request, try_result_not_found,
|
||||
try_unwrap_body, RedirectParams,
|
||||
try_unwrap_body, PlaybackSource, RedirectParams,
|
||||
},
|
||||
endpoints::{
|
||||
hls_api::{
|
||||
@@ -155,6 +155,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
stream_ext: Option<&str>,
|
||||
archive_discriminator: Option<&str>,
|
||||
provider_allocation_id: Option<u64>,
|
||||
source: Option<PlaybackSource<'_>>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let target_name = &target.name;
|
||||
if !target.has_output(TargetType::M3u) {
|
||||
@@ -183,7 +184,30 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
}
|
||||
let virtual_id = pli.virtual_id;
|
||||
|
||||
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
|
||||
let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
virtual_id,
|
||||
)
|
||||
.await;
|
||||
let is_group = source.as_ref().map_or_else(
|
||||
|| {
|
||||
matches!(
|
||||
resolved_target,
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(_)
|
||||
| tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { .. }
|
||||
)
|
||||
},
|
||||
|s| matches!(s, PlaybackSource::Group { .. }),
|
||||
);
|
||||
|
||||
let check_vid = match &resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group)
|
||||
| tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, .. } => group.group_vid,
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => virtual_id,
|
||||
};
|
||||
if app_state.active_users.is_user_blocked_for_stream(&user.username, check_vid).await {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
@@ -249,23 +273,25 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
}
|
||||
|
||||
let cluster = pli.item_type.cluster();
|
||||
pli.url = match resolve_initial_stalker_playback_url(
|
||||
app_state,
|
||||
&input,
|
||||
&user,
|
||||
pli.get_provider_id().unwrap_or_default(),
|
||||
cluster,
|
||||
pli.item_type,
|
||||
&pli.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string()));
|
||||
return axum::http::StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
if !is_group {
|
||||
pli.url = match resolve_initial_stalker_playback_url(
|
||||
app_state,
|
||||
&input,
|
||||
&user,
|
||||
pli.get_provider_id().unwrap_or_default(),
|
||||
cluster,
|
||||
pli.item_type,
|
||||
&pli.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string()));
|
||||
return axum::http::StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let stream_channel = || {
|
||||
let mut channel = pli.to_stream_channel(target.id);
|
||||
@@ -355,11 +381,13 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
}
|
||||
if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(extension)) {
|
||||
// partial request means we are in reverse proxy mode, seek happened
|
||||
let (input, stream_channel) =
|
||||
crate::api::api_utils::sticky_playback_context(app_state, session, &input, stream_channel());
|
||||
return force_provider_stream_response(
|
||||
fingerprint,
|
||||
app_state,
|
||||
session,
|
||||
stream_channel(),
|
||||
stream_channel,
|
||||
crate::api::api_utils::ForceStreamRequestContext {
|
||||
req_headers,
|
||||
input: &input,
|
||||
@@ -382,7 +410,9 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
pli.url.clone()
|
||||
};
|
||||
|
||||
if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) {
|
||||
// A group checks maintenance per member during selection; its representative input says nothing.
|
||||
if !is_group && !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false)
|
||||
{
|
||||
return maintenance_rejection_response(
|
||||
app_state,
|
||||
fingerprint,
|
||||
@@ -527,16 +557,47 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
|
||||
None
|
||||
};
|
||||
|
||||
let bound_prefer = user_session
|
||||
.as_ref()
|
||||
.and_then(|session| session.member_binding.as_ref())
|
||||
.map(|b| (b.group_vid, b.member_index));
|
||||
|
||||
let mut source = source.unwrap_or_else(|| match resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => {
|
||||
let prefer = bound_prefer.filter(|(gvid, _)| *gvid == group.group_vid.get()).map(|(_, idx)| idx);
|
||||
PlaybackSource::Group { group, mode: tuliprox_session::channel_group::SelectionMode::Live, prefer }
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
PlaybackSource::Group {
|
||||
group,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
prefer: Some(index),
|
||||
}
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => {
|
||||
PlaybackSource::Single { input: &input, stream_url: &session_url }
|
||||
}
|
||||
});
|
||||
|
||||
if let PlaybackSource::Group { group, prefer, .. } = &mut source {
|
||||
if prefer.is_none() {
|
||||
if let Some((gvid, idx)) = bound_prefer {
|
||||
if gvid == group.group_vid.get() {
|
||||
*prefer = Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream_response_with_provider_handle(
|
||||
fingerprint,
|
||||
app_state,
|
||||
&session_key,
|
||||
Some(request_class),
|
||||
stream_channel().with_epg_reference_ts(archive_reference),
|
||||
&session_url,
|
||||
source,
|
||||
pinned_provider,
|
||||
req_headers,
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
connection_permission,
|
||||
@@ -716,6 +777,7 @@ async fn m3u_api_stream(
|
||||
Some(archive.extension()),
|
||||
Some(&discriminator),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
@@ -723,10 +785,25 @@ async fn m3u_api_stream(
|
||||
|
||||
let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id);
|
||||
let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse());
|
||||
let pli =
|
||||
match m3u_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target).await {
|
||||
Ok(pli) => pli,
|
||||
Err(err) => {
|
||||
let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
shared::model::VirtualId::new(req_virtual_id),
|
||||
)
|
||||
.await;
|
||||
let pli_res =
|
||||
m3u_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target).await;
|
||||
let pli = match pli_res {
|
||||
Ok(pli) => pli,
|
||||
Err(err) => match &resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => {
|
||||
tuliprox_repository::target_stream_item::member_to_m3u_item(group, 0, &target)
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
tuliprox_repository::target_stream_item::member_to_m3u_item(group, *index, &target)
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => {
|
||||
error!("Failed to read m3u item for stream id {req_virtual_id}: {err}");
|
||||
if stream_ext == Some(HLS_EXT) {
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
@@ -738,7 +815,8 @@ async fn m3u_api_stream(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let input = try_option_bad_request!(
|
||||
app_state.app_config.get_input_by_name(&pli.input_name),
|
||||
@@ -756,6 +834,21 @@ async fn m3u_api_stream(
|
||||
} else {
|
||||
(pli, None)
|
||||
};
|
||||
let source = match resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => Some(PlaybackSource::Group {
|
||||
group,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
prefer: None,
|
||||
}),
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
Some(PlaybackSource::Group {
|
||||
group,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
prefer: Some(index),
|
||||
})
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => None,
|
||||
};
|
||||
m3u_api_stream_loaded(
|
||||
user,
|
||||
target,
|
||||
@@ -767,6 +860,7 @@ async fn m3u_api_stream(
|
||||
stream_ext,
|
||||
archive_discriminator.as_deref(),
|
||||
None,
|
||||
source,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
@@ -843,6 +937,7 @@ async fn m3u_api_stream_nested(
|
||||
stream_ext,
|
||||
archive_discriminator.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
@@ -911,6 +1006,7 @@ async fn m3u_api_catchup(
|
||||
None,
|
||||
Some(&archive_discriminator),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
|
||||
@@ -430,6 +430,7 @@ mod tests {
|
||||
let channel = StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 70,
|
||||
group_virtual_id: None,
|
||||
provider_id: 1,
|
||||
input_name: "input".intern(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
|
||||
@@ -313,6 +313,7 @@ pub(super) async fn playlist_recording_stream(
|
||||
None,
|
||||
None,
|
||||
query.provider_allocation_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
|
||||
@@ -17,3 +17,4 @@ mod behavior_epg_preview;
|
||||
mod behavior_manual_update;
|
||||
mod behavior_provider_resolution;
|
||||
mod behavior_recording_routes;
|
||||
mod channel_groups;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::{api::api_utils::tests::group_playback_support::GroupPlaybackEnv, model::Epg};
|
||||
use shared::{
|
||||
model::{EpgChannel, EpgProgramme},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_epg(channel_id: &str, title: &str, programme_title: &str) -> Epg {
|
||||
let mut programme = EpgProgramme::new(3600, 7200, channel_id.intern());
|
||||
programme.title = Some(programme_title.intern());
|
||||
Epg {
|
||||
priority: 0,
|
||||
logo_override: false,
|
||||
attributes: None,
|
||||
children: vec![Arc::new(EpgChannel {
|
||||
id: channel_id.intern(),
|
||||
title: Some(title.intern()),
|
||||
icon: None,
|
||||
programmes: vec![programme],
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_groups_outputs_and_epg_sanity() {
|
||||
let epg = test_epg("zdf.de", "ZDF", "Heute Journal");
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"])
|
||||
.without_plain_channel()
|
||||
.with_member_archives(&[Some(3), Some(7)])
|
||||
.with_epg(epg, "zdf.de")
|
||||
.with_hdhomerun()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let member_solis = env.member_vid("solis");
|
||||
let member_korath = env.member_vid("korath");
|
||||
let group_vid = env.group_vid;
|
||||
|
||||
// 1. Xtream get_live_streams lists the group vid, no member vid, and tv_archive/tv_archive_duration equals member maximum
|
||||
let res = env.get_xtream_live_streams().await;
|
||||
assert_eq!(res.status(), 200);
|
||||
let body_text = GroupPlaybackEnv::response_text(res).await;
|
||||
let streams: serde_json::Value = serde_json::from_str(&body_text).expect("valid JSON array");
|
||||
let list = streams.as_array().expect("array of streams");
|
||||
assert_eq!(list.len(), 1, "Only group stream should be listed: {body_text}");
|
||||
assert_eq!(list[0]["stream_id"], group_vid);
|
||||
assert_eq!(list[0]["name"], "ZDF HD");
|
||||
assert_eq!(list[0]["epg_channel_id"], "zdf.de");
|
||||
assert_eq!(list[0]["tv_archive"], 1);
|
||||
assert_eq!(list[0]["tv_archive_duration"], 7); // maximum of 3 and 7
|
||||
assert!(!body_text.contains(&member_solis.to_string()), "must not contain member solis vid");
|
||||
assert!(!body_text.contains(&member_korath.to_string()), "must not contain member korath vid");
|
||||
|
||||
// 2. M3U output lists one entry with the group EPG id
|
||||
let res = env.get_m3u_playlist().await;
|
||||
assert_eq!(res.status(), 200);
|
||||
let m3u_text = GroupPlaybackEnv::response_text(res).await;
|
||||
assert_eq!(m3u_text.matches("#EXTINF:").count(), 1, "M3U must contain exactly one stream: {m3u_text}");
|
||||
assert!(m3u_text.contains(r#"tvg-id="zdf.de""#), "M3U must have group EPG id: {m3u_text}");
|
||||
assert!(m3u_text.contains(&format!("/{group_vid}")), "M3U stream URL must reference group vid: {m3u_text}");
|
||||
assert!(!m3u_text.contains(&format!("/{member_solis}")), "M3U must not contain member solis vid");
|
||||
assert!(!m3u_text.contains(&format!("/{member_korath}")), "M3U must not contain member korath vid");
|
||||
|
||||
// 3. HDHomeRun lineup lists one entry
|
||||
let res = env.get_hdhomerun_lineup().await;
|
||||
assert_eq!(res.status(), 200);
|
||||
let lineup_text = GroupPlaybackEnv::response_text(res).await;
|
||||
let lineup: serde_json::Value = serde_json::from_str(&lineup_text).expect("valid JSON array");
|
||||
let lineup_list = lineup.as_array().expect("array of lineup items");
|
||||
assert_eq!(lineup_list.len(), 1, "Lineup must contain exactly one item: {lineup_text}");
|
||||
assert_eq!(lineup_list[0]["GuideNumber"], "zdf.de");
|
||||
assert_eq!(lineup_list[0]["GuideName"], "ZDF HD");
|
||||
let lineup_url = lineup_list[0]["URL"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
lineup_url.contains(&group_vid.to_string()),
|
||||
"Lineup URL must reference group vid: {lineup_url} vs group {group_vid}"
|
||||
);
|
||||
assert!(
|
||||
!lineup_url.ends_with(&format!("/{member_solis}")) && !lineup_url.ends_with(&format!("/{member_solis}.ts")),
|
||||
"Lineup must not end with member solis vid: {lineup_url} vs solis {member_solis}"
|
||||
);
|
||||
assert!(
|
||||
!lineup_url.ends_with(&format!("/{member_korath}")) && !lineup_url.ends_with(&format!("/{member_korath}.ts")),
|
||||
"Lineup must not end with member korath vid: {lineup_url} vs korath {member_korath}"
|
||||
);
|
||||
|
||||
// 4. XMLTV contains the group EPG channel
|
||||
let res = env.get_xmltv_epg().await;
|
||||
assert_eq!(res.status(), 200);
|
||||
let xmltv_text = GroupPlaybackEnv::response_text(res).await;
|
||||
assert!(xmltv_text.contains(r#"<channel id="zdf.de">"#), "XMLTV must contain group EPG channel: {xmltv_text}");
|
||||
assert!(
|
||||
xmltv_text.contains("<display-name>ZDF HD</display-name>"),
|
||||
"XMLTV must contain channel display name: {xmltv_text}"
|
||||
);
|
||||
assert!(
|
||||
xmltv_text.contains(r#"channel="zdf.de""#),
|
||||
"XMLTV must have programme referencing group EPG channel: {xmltv_text}"
|
||||
);
|
||||
assert!(xmltv_text.contains("<title>Heute Journal</title>"), "XMLTV must contain programme title: {xmltv_text}");
|
||||
}
|
||||
@@ -145,6 +145,7 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man
|
||||
qos_aggregation: CancellationToken::new(),
|
||||
recordings: CancellationToken::new(),
|
||||
hls_cache: CancellationToken::new(),
|
||||
channel_groups: tokio_util::sync::CancellationToken::new(),
|
||||
};
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
|
||||
@@ -172,5 +173,6 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man
|
||||
auth: crate::api::model::AuthState::for_tests(),
|
||||
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests_with_sender(manual_update_sender),
|
||||
maintenance: crate::api::model::create_test_maintenance(""),
|
||||
member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(super) async fn playlist_webplayer_stream(
|
||||
);
|
||||
let user = Arc::new(create_api_proxy_user(&app_state));
|
||||
|
||||
m3u_api_stream_loaded(user, target, &fingerprint, &req_headers, &app_state, pli, input, None, None, None)
|
||||
m3u_api_stream_loaded(user, target, &fingerprint, &req_headers, &app_state, pli, input, None, None, None, None)
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -540,7 +540,11 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static
|
||||
// Auth decisions reach notification channels and plugins, not the
|
||||
// Web UI socket: there is no panel that renders them, and pushing
|
||||
// every sign-in to every connected admin is noise.
|
||||
| EventMessage::AuthAudit(_) => return None,
|
||||
| EventMessage::AuthAudit(_)
|
||||
| EventMessage::ChannelGroupMemberSelected { .. }
|
||||
| EventMessage::ChannelGroupExhausted { .. }
|
||||
| EventMessage::ChannelGroupCircuitChanged { .. }
|
||||
| EventMessage::ChannelGroupMemberSwitched { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -748,7 +752,7 @@ mod tests {
|
||||
// every notification-only kind against `expected = true` and the
|
||||
// test failed on `DiskAlert` - it has been red since the lifecycle
|
||||
// events joined the bus.
|
||||
const NOT_ON_THE_WIRE: [EventKind; 28] = [
|
||||
const NOT_ON_THE_WIRE: [EventKind; 32] = [
|
||||
EventKind::ConnectionDenied,
|
||||
EventKind::ScheduledTaskFailed,
|
||||
EventKind::ProviderFetchFailed,
|
||||
@@ -777,6 +781,10 @@ mod tests {
|
||||
EventKind::AuthSignInFailed,
|
||||
EventKind::AuthSignInThrottled,
|
||||
EventKind::AuthPermissionDenied,
|
||||
EventKind::ChannelGroupMemberSelected,
|
||||
EventKind::ChannelGroupExhausted,
|
||||
EventKind::ChannelGroupCircuitChanged,
|
||||
EventKind::ChannelGroupMemberSwitched,
|
||||
];
|
||||
|
||||
for (event, kind) in sample_event_of_every_kind() {
|
||||
@@ -941,6 +949,25 @@ mod tests {
|
||||
auth_audit(AuthAuditOutcome::SignInThrottled),
|
||||
auth_audit(AuthAuditOutcome::PermissionDenied),
|
||||
EventMessage::MaintenanceChanged(Arc::default()),
|
||||
EventMessage::ChannelGroupMemberSelected {
|
||||
target_id: 1,
|
||||
group_vid: 10,
|
||||
member_vid: 11,
|
||||
tier: shared::model::channel_group::CapacityTier::Free,
|
||||
},
|
||||
EventMessage::ChannelGroupExhausted { target_id: 1, group_vid: 10 },
|
||||
EventMessage::ChannelGroupMemberSwitched {
|
||||
target_id: 1,
|
||||
group_vid: 10,
|
||||
from_member_vid: 11,
|
||||
to_member_vid: 12,
|
||||
reason: shared::model::channel_group::SwitchReason::UpstreamEof,
|
||||
},
|
||||
EventMessage::ChannelGroupCircuitChanged {
|
||||
input_name: "test".into(),
|
||||
input_stream_id: "1".into(),
|
||||
state: shared::model::channel_group::CircuitState::Closed,
|
||||
},
|
||||
];
|
||||
assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list");
|
||||
samples
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
redirect, redirect_response, reentry_suppressed_response, resolve_initial_stalker_playback_url,
|
||||
resource_proxy_response, resource_redirect_or_proxy, separate_number_and_remainder,
|
||||
should_allow_exhausted_shared_reconnect, stream_response, stream_response_with_provider_handle,
|
||||
try_option_bad_request, try_result_bad_request, try_unwrap_body, RedirectParams,
|
||||
try_option_bad_request, try_result_bad_request, try_unwrap_body, PlaybackSource, RedirectParams,
|
||||
},
|
||||
endpoints::{
|
||||
hls_api::{
|
||||
@@ -295,27 +295,53 @@ async fn xtream_player_api_stream(
|
||||
}
|
||||
|
||||
let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse());
|
||||
let Ok(mut pli) =
|
||||
xtream_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target, None).await
|
||||
else {
|
||||
error!("Failed to read xtream item for stream id {req_virtual_id}");
|
||||
if is_hls_manifest_request {
|
||||
return hls_custom_video_manifest_response(
|
||||
app_state,
|
||||
&user,
|
||||
CustomVideoStreamType::ChannelUnavailable,
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.provider_stream_ctx(),
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::ChannelUnavailable,
|
||||
)
|
||||
.into_response();
|
||||
let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id(
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
VirtualId::new(req_virtual_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
let is_group = matches!(
|
||||
resolved_target,
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(_)
|
||||
| tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { .. }
|
||||
);
|
||||
|
||||
let pli_res =
|
||||
xtream_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target, None).await;
|
||||
let mut pli = match pli_res {
|
||||
Ok(pli) => pli,
|
||||
Err(err) => match &resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => {
|
||||
tuliprox_repository::target_stream_item::member_to_xtream_item(group, 0, &target)
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
tuliprox_repository::target_stream_item::member_to_xtream_item(group, *index, &target)
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => {
|
||||
error!("Failed to read xtream item for stream id {req_virtual_id}: {err}");
|
||||
if is_hls_manifest_request {
|
||||
return hls_custom_video_manifest_response(
|
||||
app_state,
|
||||
&user,
|
||||
CustomVideoStreamType::ChannelUnavailable,
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.provider_stream_ctx(),
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::ChannelUnavailable,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let virtual_id = pli.virtual_id;
|
||||
let input_option = app_state.app_config.get_input_by_name(&pli.input_name);
|
||||
let stream_ext = input_option
|
||||
.as_deref()
|
||||
@@ -346,8 +372,12 @@ async fn xtream_player_api_stream(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let virtual_id = pli.virtual_id;
|
||||
if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await {
|
||||
let check_vid = match &resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group)
|
||||
| tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, .. } => group.group_vid,
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => virtual_id,
|
||||
};
|
||||
if app_state.active_users.is_user_blocked_for_stream(&user.username, check_vid).await {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
@@ -441,23 +471,25 @@ async fn xtream_player_api_stream(
|
||||
(pli.xtream_cluster, pli.item_type)
|
||||
};
|
||||
|
||||
pli.url = match resolve_initial_stalker_playback_url(
|
||||
app_state,
|
||||
&input,
|
||||
&user,
|
||||
pli.provider_id,
|
||||
pli.xtream_cluster,
|
||||
item_type,
|
||||
&pli.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string()));
|
||||
return axum::http::StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
if !is_group {
|
||||
pli.url = match resolve_initial_stalker_playback_url(
|
||||
app_state,
|
||||
&input,
|
||||
&user,
|
||||
pli.provider_id,
|
||||
pli.xtream_cluster,
|
||||
item_type,
|
||||
&pli.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string()));
|
||||
return axum::http::StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let requested_extension = if m3u_timeshift.is_some() {
|
||||
extract_extension_from_url(&pli.url).map(|ext| concat_string!(".", ext))
|
||||
@@ -555,6 +587,8 @@ async fn xtream_player_api_stream(
|
||||
if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(playback_ext))
|
||||
{
|
||||
// partial request means we are in reverse proxy mode, seek happened
|
||||
let (input, stream_channel) =
|
||||
crate::api::api_utils::sticky_playback_context(app_state, session, &input, stream_channel);
|
||||
return force_provider_stream_response(
|
||||
fingerprint,
|
||||
app_state,
|
||||
@@ -579,7 +613,9 @@ async fn xtream_player_api_stream(
|
||||
pli.url.clone()
|
||||
};
|
||||
|
||||
if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) {
|
||||
// A group checks maintenance per member during selection; its representative input says nothing.
|
||||
if !is_group && !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false)
|
||||
{
|
||||
let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type);
|
||||
return maintenance_rejection_response(
|
||||
app_state,
|
||||
@@ -715,16 +751,40 @@ async fn xtream_player_api_stream(
|
||||
let pinned_provider =
|
||||
user_session.as_ref().filter(|_| item_type.requires_provider_affinity()).map(|session| &session.provider);
|
||||
|
||||
let source = match resolved_target {
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => {
|
||||
let bound_prefer = user_session
|
||||
.as_ref()
|
||||
.and_then(|session| session.member_binding.as_ref())
|
||||
.filter(|b| b.group_vid == group.group_vid.get())
|
||||
.map(|b| b.member_index);
|
||||
PlaybackSource::Group {
|
||||
group,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
prefer: bound_prefer,
|
||||
}
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => {
|
||||
PlaybackSource::Group {
|
||||
group,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
prefer: Some(index),
|
||||
}
|
||||
}
|
||||
tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => {
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url }
|
||||
}
|
||||
};
|
||||
|
||||
stream_response(
|
||||
fingerprint,
|
||||
app_state,
|
||||
session_key.as_str(),
|
||||
Some(request_class),
|
||||
stream_channel,
|
||||
&stream_url,
|
||||
source,
|
||||
pinned_provider,
|
||||
req_headers,
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
connection_permission,
|
||||
@@ -1077,10 +1137,9 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target(
|
||||
session_key.as_str(),
|
||||
None,
|
||||
pli.to_stream_channel(target.id),
|
||||
&stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url },
|
||||
None,
|
||||
req_headers,
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
UserConnectionPermission::Allowed,
|
||||
|
||||
@@ -912,6 +912,7 @@ async fn occupy_the_only_connection(app_state: &Arc<AppState>, user: &ProxyUserC
|
||||
let channel = shared::model::StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 7,
|
||||
group_virtual_id: None,
|
||||
provider_id: 7,
|
||||
input_name: "provider-b".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
|
||||
Reference in New Issue
Block a user