Feature/failover (#924)

* feature stream failover
This commit is contained in:
euzu
2026-10-16 21:10:07 +02:00
committed by GitHub
parent 86d19f955c
commit 0e4e01409c
203 changed files with 15664 additions and 820 deletions
@@ -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()),
})
}
+124 -28
View File
@@ -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()
+1
View File
@@ -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()
}
+29 -2
View File
@@ -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
+103 -44
View File
@@ -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,