mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-28 17:52:50 +01:00
feature stream failover
This commit is contained in:
Generated
+2
@@ -4917,6 +4917,8 @@ dependencies = [
|
||||
"lru",
|
||||
"rand 0.9.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::{api::model::AppState, model::ConfigInput};
|
||||
use shared::model::{ChannelGroupPolicy, ChannelGroupSelectionConfig, StreamChannel};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tuliprox_repository::channel_group_repository::{ChannelGroupRecord, GroupMember};
|
||||
pub(crate) use tuliprox_session::channel_group::{member_key, CircuitAdmission, CircuitView, SelectionMode};
|
||||
use tuliprox_session::{
|
||||
active_provider_manager::{CapacityTier, ConnectionKind, ManagedProviderHandle, PlaybackLeaseRef},
|
||||
channel_group::{rank_members, HalfOpenGuard, RankedCandidate},
|
||||
};
|
||||
|
||||
pub(crate) enum PlaybackSource<'a> {
|
||||
Single { input: &'a Arc<ConfigInput>, stream_url: &'a str },
|
||||
Group { group: Arc<ChannelGroupRecord>, mode: SelectionMode, prefer: Option<usize> },
|
||||
}
|
||||
|
||||
pub(crate) struct ResolvedGroupPlayback {
|
||||
pub acquired: AcquiredMember,
|
||||
pub member: GroupMember,
|
||||
pub input: Arc<ConfigInput>,
|
||||
pub stream_url: Arc<str>,
|
||||
pub stream_channel: StreamChannel,
|
||||
}
|
||||
|
||||
pub(crate) fn group_shared_key(target_id: u16, group_vid: u32) -> Arc<str> {
|
||||
Arc::from(format!("group:{target_id}:{group_vid}"))
|
||||
}
|
||||
|
||||
pub(crate) struct GroupSelectionRequest<'a> {
|
||||
pub target_name: &'a Arc<str>,
|
||||
pub group: &'a ChannelGroupRecord,
|
||||
pub policy: ChannelGroupPolicy,
|
||||
pub mode: SelectionMode,
|
||||
pub selection: ChannelGroupSelectionConfig,
|
||||
pub addr: &'a SocketAddr,
|
||||
pub username: &'a str,
|
||||
pub priority: i8,
|
||||
pub kind: ConnectionKind,
|
||||
pub lease: Option<PlaybackLeaseRef<'a>>,
|
||||
pub allow_provider_grace: bool,
|
||||
pub exclude: &'a [usize],
|
||||
pub demoted_inputs: &'a [Arc<str>],
|
||||
pub prefer: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct AcquiredMember {
|
||||
pub index: usize,
|
||||
pub handle: ManagedProviderHandle,
|
||||
pub tier: CapacityTier,
|
||||
pub half_open: Option<HalfOpenGuard>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GroupSelectionFailure {
|
||||
Exhausted,
|
||||
NoCandidate,
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_group_member(
|
||||
app_state: &Arc<AppState>,
|
||||
req: GroupSelectionRequest<'_>,
|
||||
) -> Result<AcquiredMember, GroupSelectionFailure> {
|
||||
acquire_group_member_inner(app_state, req, None::<fn(&Arc<AppState>)>).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn acquire_group_member_with_race<F: FnOnce(&Arc<AppState>)>(
|
||||
app_state: &Arc<AppState>,
|
||||
req: GroupSelectionRequest<'_>,
|
||||
after_probe: F,
|
||||
) -> Result<AcquiredMember, GroupSelectionFailure> {
|
||||
acquire_group_member_inner(app_state, req, Some(after_probe)).await
|
||||
}
|
||||
|
||||
async fn acquire_group_member_inner<F: FnOnce(&Arc<AppState>)>(
|
||||
app_state: &Arc<AppState>,
|
||||
req: GroupSelectionRequest<'_>,
|
||||
after_probe: Option<F>,
|
||||
) -> Result<AcquiredMember, GroupSelectionFailure> {
|
||||
let budget_secs = req.selection.startup_budget_secs;
|
||||
if budget_secs > 0 {
|
||||
match tokio::time::timeout(Duration::from_secs(budget_secs), select_member_loop(app_state, req, after_probe))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(GroupSelectionFailure::Exhausted),
|
||||
}
|
||||
} else {
|
||||
select_member_loop(app_state, req, after_probe).await
|
||||
}
|
||||
}
|
||||
|
||||
fn check_member_circuit(
|
||||
app_state: &Arc<AppState>,
|
||||
req: &GroupSelectionRequest<'_>,
|
||||
cand_idx: usize,
|
||||
first_remaining_idx: usize,
|
||||
all_circuits_open: bool,
|
||||
) -> (bool, Option<HalfOpenGuard>) {
|
||||
let key = member_key(&req.group.members[cand_idx]);
|
||||
let admission = app_state.member_health.admit(&key, std::time::Instant::now());
|
||||
match admission {
|
||||
CircuitAdmission::Closed => (true, None),
|
||||
CircuitAdmission::HalfOpen(guard) => (true, Some(guard)),
|
||||
CircuitAdmission::Open => (all_circuits_open && cand_idx == first_remaining_idx, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_tier_candidates<F>(
|
||||
app_state: &Arc<AppState>,
|
||||
req: &GroupSelectionRequest<'_>,
|
||||
remaining: &mut Vec<RankedCandidate>,
|
||||
probed_tiers: &mut Vec<(usize, CapacityTier)>,
|
||||
all_circuits_open: bool,
|
||||
filter_tier: F,
|
||||
probe_tier: &dyn Fn(usize) -> CapacityTier,
|
||||
) -> Option<AcquiredMember>
|
||||
where
|
||||
F: Fn(CapacityTier) -> Option<bool>,
|
||||
{
|
||||
loop {
|
||||
let mut tried_candidate = false;
|
||||
let candidate_indices: Vec<(usize, bool)> = probed_tiers
|
||||
.iter()
|
||||
.filter_map(|(idx, tier)| filter_tier(*tier).map(|allow_grace| (*idx, allow_grace)))
|
||||
.collect();
|
||||
|
||||
for (cand_idx, allow_grace) in candidate_indices {
|
||||
let Some((_, tier)) = probed_tiers.iter().find(|(idx, _)| *idx == cand_idx).copied() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let (can_try, half_open_guard) =
|
||||
check_member_circuit(app_state, req, cand_idx, remaining[0].index, all_circuits_open);
|
||||
if !can_try {
|
||||
continue;
|
||||
}
|
||||
|
||||
tried_candidate = true;
|
||||
let handle_opt = app_state.active_provider.try_acquire_connection_no_preempt(
|
||||
&req.group.members[cand_idx].input_name,
|
||||
allow_grace,
|
||||
req.addr,
|
||||
req.priority,
|
||||
req.kind,
|
||||
req.lease,
|
||||
);
|
||||
|
||||
if let Some(handle) = handle_opt {
|
||||
return Some(AcquiredMember {
|
||||
index: cand_idx,
|
||||
handle: ManagedProviderHandle::new(Arc::clone(&app_state.active_provider), handle),
|
||||
tier,
|
||||
half_open: half_open_guard,
|
||||
});
|
||||
}
|
||||
|
||||
remaining.retain(|c| c.index != cand_idx);
|
||||
*probed_tiers = remaining.iter().map(|c| (c.index, probe_tier(c.index))).collect();
|
||||
break;
|
||||
}
|
||||
|
||||
if !tried_candidate {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn try_acquire_preempt(
|
||||
app_state: &Arc<AppState>,
|
||||
req: &GroupSelectionRequest<'_>,
|
||||
remaining: &[RankedCandidate],
|
||||
all_circuits_open: bool,
|
||||
) -> Option<AcquiredMember> {
|
||||
for c in remaining {
|
||||
let (can_try, half_open_guard) =
|
||||
check_member_circuit(app_state, req, c.index, remaining[0].index, all_circuits_open);
|
||||
if !can_try {
|
||||
continue;
|
||||
}
|
||||
|
||||
let handle_opt = app_state
|
||||
.active_provider
|
||||
.acquire_connection_with_lease_for_session_await(
|
||||
&req.group.members[c.index].input_name,
|
||||
req.addr,
|
||||
req.allow_provider_grace,
|
||||
req.priority,
|
||||
req.kind,
|
||||
req.lease,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(handle) = handle_opt {
|
||||
return Some(AcquiredMember {
|
||||
index: c.index,
|
||||
handle: ManagedProviderHandle::new(Arc::clone(&app_state.active_provider), handle),
|
||||
tier: CapacityTier::Preempt,
|
||||
half_open: half_open_guard,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn select_member_loop<F: FnOnce(&Arc<AppState>)>(
|
||||
app_state: &Arc<AppState>,
|
||||
req: GroupSelectionRequest<'_>,
|
||||
after_probe: Option<F>,
|
||||
) -> Result<AcquiredMember, GroupSelectionFailure> {
|
||||
let now = std::time::Instant::now();
|
||||
let mut ranked =
|
||||
rank_members(req.target_name, &req.group.members, req.policy, &app_state.member_health, &req.mode, now);
|
||||
|
||||
ranked.retain(|c| !req.exclude.contains(&c.index));
|
||||
if ranked.is_empty() {
|
||||
return Err(GroupSelectionFailure::NoCandidate);
|
||||
}
|
||||
|
||||
ranked.sort_by_key(|c| req.demoted_inputs.contains(&req.group.members[c.index].input_name));
|
||||
|
||||
if let Some(prefer_idx) = req.prefer {
|
||||
if let Some(pos) = ranked.iter().position(|c| c.index == prefer_idx) {
|
||||
let cand = ranked.remove(pos);
|
||||
ranked.insert(0, cand);
|
||||
}
|
||||
}
|
||||
|
||||
let all_circuits_open = ranked.iter().all(|c| {
|
||||
let key = member_key(&req.group.members[c.index]);
|
||||
matches!(app_state.member_health.circuit_view(&key, now), CircuitView::Open { remaining } if remaining > Duration::ZERO)
|
||||
});
|
||||
|
||||
let own_releasing = app_state.active_users.pending_release_allocations(req.username).await;
|
||||
|
||||
let probe_tier = |index: usize| {
|
||||
app_state.active_provider.capacity_probe(
|
||||
&req.group.members[index].input_name,
|
||||
req.priority,
|
||||
req.kind,
|
||||
req.lease.map(|l| l.provider_owner()),
|
||||
&own_releasing,
|
||||
)
|
||||
};
|
||||
|
||||
let mut remaining = ranked;
|
||||
let mut probed_tiers: Vec<(usize, CapacityTier)> =
|
||||
remaining.iter().map(|c| (c.index, probe_tier(c.index))).collect();
|
||||
|
||||
if let Some(hook) = after_probe {
|
||||
hook(app_state);
|
||||
}
|
||||
|
||||
// Tier 1: Free ∪ OwnRelease
|
||||
let tier1_match = |tier| match tier {
|
||||
CapacityTier::OwnRelease => Some(true),
|
||||
CapacityTier::Free => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(acquired) = select_tier_candidates(
|
||||
app_state,
|
||||
&req,
|
||||
&mut remaining,
|
||||
&mut probed_tiers,
|
||||
all_circuits_open,
|
||||
tier1_match,
|
||||
&probe_tier,
|
||||
) {
|
||||
return Ok(acquired);
|
||||
}
|
||||
|
||||
// Tier 2: Grace (only if allow_provider_grace)
|
||||
if req.allow_provider_grace {
|
||||
let tier2_match = |tier| match tier {
|
||||
CapacityTier::Grace => Some(true),
|
||||
CapacityTier::Free => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(acquired) = select_tier_candidates(
|
||||
app_state,
|
||||
&req,
|
||||
&mut remaining,
|
||||
&mut probed_tiers,
|
||||
all_circuits_open,
|
||||
tier2_match,
|
||||
&probe_tier,
|
||||
) {
|
||||
return Ok(acquired);
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: Preempt on the best-ranked candidate
|
||||
if let Some(acquired) = try_acquire_preempt(app_state, &req, &remaining, all_circuits_open).await {
|
||||
return Ok(acquired);
|
||||
}
|
||||
|
||||
Err(GroupSelectionFailure::Exhausted)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_hls_group_entry(
|
||||
app_state: &Arc<AppState>,
|
||||
target: &tuliprox_core::model::ConfigTarget,
|
||||
group: Arc<ChannelGroupRecord>,
|
||||
req: GroupSelectionRequest<'_>,
|
||||
) -> Result<ResolvedGroupPlayback, GroupSelectionFailure> {
|
||||
let max_attempts = u32::from(req.selection.max_start_attempts.max(1));
|
||||
let mut attempts = 0;
|
||||
let start_time = std::time::Instant::now();
|
||||
let budget = Duration::from_secs(req.selection.startup_budget_secs);
|
||||
let mut exclude: Vec<usize> = req.exclude.to_vec();
|
||||
let demoted_inputs: Vec<Arc<str>> = req.demoted_inputs.to_vec();
|
||||
|
||||
while attempts < max_attempts && (req.selection.startup_budget_secs == 0 || start_time.elapsed() < budget) {
|
||||
attempts += 1;
|
||||
let attempt_req = GroupSelectionRequest {
|
||||
target_name: req.target_name,
|
||||
group: req.group,
|
||||
policy: req.policy,
|
||||
mode: req.mode.clone(),
|
||||
selection: req.selection,
|
||||
addr: req.addr,
|
||||
username: req.username,
|
||||
priority: req.priority,
|
||||
kind: req.kind,
|
||||
lease: req.lease,
|
||||
allow_provider_grace: req.allow_provider_grace,
|
||||
exclude: &exclude,
|
||||
demoted_inputs: &demoted_inputs,
|
||||
prefer: if attempts == 1 { req.prefer } else { None },
|
||||
};
|
||||
|
||||
let acquired = acquire_group_member(app_state, attempt_req).await?;
|
||||
let member = group.members[acquired.index].clone();
|
||||
let Some(member_input) = app_state.app_config.get_input_by_name(&member.input_name) else {
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
tuliprox_session::channel_group::FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
tuliprox_session::channel_group::FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
drop(acquired.handle);
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut stream_channel = if target.has_output(shared::model::TargetType::Xtream) {
|
||||
tuliprox_repository::target_stream_item::member_to_xtream_item(&group, acquired.index, target)
|
||||
.to_stream_channel(target.id)
|
||||
} else {
|
||||
tuliprox_repository::target_stream_item::member_to_m3u_item(&group, acquired.index, target)
|
||||
.to_stream_channel(target.id)
|
||||
};
|
||||
stream_channel.group_virtual_id = Some(group.group_vid.get());
|
||||
|
||||
let stream_url = Arc::clone(&member.url);
|
||||
return Ok(ResolvedGroupPlayback { acquired, member, input: member_input, stream_url, stream_channel });
|
||||
}
|
||||
|
||||
Err(GroupSelectionFailure::Exhausted)
|
||||
}
|
||||
@@ -233,11 +233,12 @@ pub enum ResourceFetchPolicy {
|
||||
const API_STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
mod activation;
|
||||
mod auth;
|
||||
mod catchup;
|
||||
pub(crate) mod channel_group;
|
||||
mod clock;
|
||||
mod local_files;
|
||||
mod maintenance;
|
||||
@@ -318,12 +319,13 @@ pub use self::{
|
||||
strategy::redirect_response,
|
||||
streaming::{
|
||||
force_provider_stream_response, is_hls_stream_share_enabled, is_seek_request, is_seekable_media_request,
|
||||
is_stream_share_enabled,
|
||||
is_stream_share_enabled, sticky_playback_context,
|
||||
},
|
||||
};
|
||||
pub(crate) use self::{
|
||||
auth::{input_for_user, with_recording_headers},
|
||||
catchup::get_catchup_session_ttl_secs,
|
||||
channel_group::{resolve_hls_group_entry, GroupSelectionRequest, PlaybackSource},
|
||||
local_files::local_stream_response,
|
||||
maintenance::{blocks_new_playback, is_running_playback, maintenance_rejection_response},
|
||||
provider_acquire::{acquire_exact_provider_handle, stream_response_with_provider_handle},
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use super::{
|
||||
activate_session_before_stream_open, admission_failure_response, blocks_new_playback,
|
||||
channel_group::{
|
||||
acquire_group_member, group_shared_key, GroupSelectionFailure, GroupSelectionRequest, PlaybackSource,
|
||||
},
|
||||
cleanup_failed_detected_catchup_hls, connection_priority_for_kind, create_stream_response_details,
|
||||
detected_catchup_hls_response, get_session_reservation_ttl_secs, get_stream_options,
|
||||
is_socket_bound_playback_session, is_stream_share_enabled, mark_response_as_uncompressed,
|
||||
no_custom_video_fallback_status, prepare_body_stream, prepare_stream_metering, probe_catchup_payload,
|
||||
record_connect_failed_attempt, reentry_suppressed_response, resolve_request_url_for_logging,
|
||||
resolve_xtream_vod_provider_url, select_provider_stream_url, should_pin_provider_for_session,
|
||||
stream_admission_rejected_response, try_shared_stream_response_if_any, try_unwrap_body, CatchupPayload,
|
||||
ConnectFailedAttempt, DetectedCatchupHlsResponseParams, ExactProviderAcquire, PlaybackRequestClass,
|
||||
SessionActivationRequest, StreamResponseMode, StreamingAcquireOptions, LEASE_EXPIRY_RECHECK_FLOOR,
|
||||
record_connect_failed_attempt, reentry_suppressed_response, resolve_initial_stalker_playback_url,
|
||||
resolve_request_url_for_logging, resolve_xtream_vod_provider_url, select_provider_stream_url,
|
||||
should_pin_provider_for_session, stream_admission_rejected_response, try_shared_stream_response_if_any,
|
||||
try_unwrap_body, CatchupPayload, ConnectFailedAttempt, DetectedCatchupHlsResponseParams, ExactProviderAcquire,
|
||||
PlaybackRequestClass, SessionActivationRequest, StreamResponseMode, StreamingAcquireOptions,
|
||||
LEASE_EXPIRY_RECHECK_FLOOR,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{
|
||||
@@ -25,13 +29,21 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use log::{debug, error, log_enabled, trace};
|
||||
use shared::{
|
||||
model::{ConnectFailureReason, FailureStage, PlaylistItemType, StreamChannel, UserConnectionPermission, VirtualId},
|
||||
model::{
|
||||
ConnectFailureReason, EventMessage, FailureStage, PlaylistItemType, StreamChannel, UserConnectionPermission,
|
||||
VirtualId,
|
||||
},
|
||||
utils::{extract_extension_from_url, sanitize_sensitive_info, Internable},
|
||||
};
|
||||
use std::{borrow::Cow, sync::Arc, time::Duration};
|
||||
use tuliprox_session::ProviderSessionHeaders;
|
||||
use tuliprox_session::{
|
||||
channel_group::{member_key, FailureClass},
|
||||
stream::StreamDetails,
|
||||
ProviderSessionHeaders,
|
||||
};
|
||||
|
||||
pub(super) fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState {
|
||||
ProviderStreamState::Custom {
|
||||
@@ -293,10 +305,9 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
session_token: &str,
|
||||
request_class: Option<PlaybackRequestClass>,
|
||||
mut stream_channel: StreamChannel,
|
||||
stream_url: &str,
|
||||
source: PlaybackSource<'_>,
|
||||
pinned_provider: Option<&Arc<str>>,
|
||||
req_headers: &HeaderMap,
|
||||
input: &Arc<ConfigInput>,
|
||||
target: &Arc<ConfigTarget>,
|
||||
user: &ProxyUserCredentials,
|
||||
connection_permission: UserConnectionPermission,
|
||||
@@ -305,26 +316,45 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
grace_mode: Option<crate::api::model::GraceMode>,
|
||||
preacquired_provider_handle: Option<tuliprox_session::ManagedProviderHandle>,
|
||||
) -> impl IntoResponse + Send {
|
||||
if blocks_new_playback(app_state, input, user, preacquired_provider_handle.is_some()) {
|
||||
return admission_failure_response(
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
stream_channel,
|
||||
Arc::clone(&input.name),
|
||||
req_headers,
|
||||
ConnectFailureReason::Maintenance,
|
||||
);
|
||||
if let PlaybackSource::Single { input, .. } = &source {
|
||||
if blocks_new_playback(app_state, input, user, preacquired_provider_handle.is_some()) {
|
||||
return admission_failure_response(
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
stream_channel,
|
||||
Arc::clone(&input.name),
|
||||
req_headers,
|
||||
ConnectFailureReason::Maintenance,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _transition_guard = app_state.active_users.acquire_playback_transition(&user.username, session_token).await;
|
||||
let request_log_stream_url = resolve_request_url_for_logging(input, stream_url);
|
||||
|
||||
if let PlaybackSource::Group { group, .. } = &source {
|
||||
stream_channel.group_virtual_id = Some(group.group_vid.get());
|
||||
}
|
||||
|
||||
let (initial_input, initial_stream_url) = match &source {
|
||||
PlaybackSource::Single { input, stream_url } => (Arc::clone(input), (*stream_url).to_string()),
|
||||
PlaybackSource::Group { group, .. } => {
|
||||
let first_member = &group.members[0];
|
||||
let cand_input = app_state
|
||||
.app_config
|
||||
.get_input_by_name(&first_member.input_name)
|
||||
.unwrap_or_else(|| Arc::new(ConfigInput::default()));
|
||||
(cand_input, first_member.url.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let request_log_stream_url = resolve_request_url_for_logging(&initial_input, &initial_stream_url);
|
||||
if log_enabled!(log::Level::Trace) {
|
||||
trace!("Try to open stream {}", sanitize_sensitive_info(request_log_stream_url.as_ref()));
|
||||
}
|
||||
|
||||
let virtual_id = stream_channel.virtual_id;
|
||||
let virtual_id = stream_channel.client_virtual_id();
|
||||
let item_type = stream_channel.item_type;
|
||||
let playback_extension = extract_extension_from_url(stream_url);
|
||||
let playback_extension = extract_extension_from_url(&initial_stream_url);
|
||||
let socket_bound = is_socket_bound_playback_session(item_type, playback_extension);
|
||||
let mut connection_permission = connection_permission;
|
||||
let mut connection_kind = connection_kind;
|
||||
@@ -332,13 +362,13 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
app_state,
|
||||
SessionActivationRequest {
|
||||
fingerprint,
|
||||
input,
|
||||
input: &initial_input,
|
||||
user,
|
||||
session_token,
|
||||
request_class,
|
||||
virtual_id: VirtualId::new(virtual_id),
|
||||
item_type,
|
||||
stream_url,
|
||||
stream_url: &initial_stream_url,
|
||||
connection_permission,
|
||||
connection_kind,
|
||||
granted_grace_mode: grace_mode,
|
||||
@@ -353,14 +383,19 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
let allow_shared_reuse =
|
||||
connection_permission != UserConnectionPermission::Exhausted || allow_exhausted_shared_reconnect;
|
||||
|
||||
let shared_lookup_key: Arc<str> = match &source {
|
||||
PlaybackSource::Group { group, .. } => group_shared_key(target.id, group.group_vid.get()),
|
||||
PlaybackSource::Single { stream_url, .. } => Arc::from(*stream_url),
|
||||
};
|
||||
|
||||
let share_stream = is_stream_share_enabled(item_type, target);
|
||||
let _shared_lock = if share_stream {
|
||||
let write_lock = app_state.app_config.file_locks.write_lock_str(stream_url).await;
|
||||
let write_lock = app_state.app_config.file_locks.write_lock_str(shared_lookup_key.as_ref()).await;
|
||||
|
||||
if allow_shared_reuse {
|
||||
if let Some(value) = try_shared_stream_response_if_any(
|
||||
app_state,
|
||||
stream_url,
|
||||
shared_lookup_key.as_ref(),
|
||||
fingerprint,
|
||||
user,
|
||||
connection_permission,
|
||||
@@ -382,7 +417,7 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
if item_type == PlaylistItemType::Live && allow_shared_reuse {
|
||||
if let Some(value) = try_shared_stream_response_if_any(
|
||||
app_state,
|
||||
stream_url,
|
||||
shared_lookup_key.as_ref(),
|
||||
fingerprint,
|
||||
user,
|
||||
connection_permission,
|
||||
@@ -394,7 +429,10 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug_if_enabled!("Opportunistic shared stream reuse for {}", sanitize_sensitive_info(stream_url));
|
||||
debug_if_enabled!(
|
||||
"Opportunistic shared stream reuse for {}",
|
||||
sanitize_sensitive_info(shared_lookup_key.as_ref())
|
||||
);
|
||||
return value.into_response();
|
||||
}
|
||||
}
|
||||
@@ -419,7 +457,7 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
fingerprint,
|
||||
user,
|
||||
stream_channel: stream_channel.clone(),
|
||||
provider_name: input.name.clone(),
|
||||
provider_name: initial_input.name.clone(),
|
||||
req_headers,
|
||||
reason: ConnectFailureReason::UserConnectionsExhausted,
|
||||
failure_stage: FailureStage::Admission,
|
||||
@@ -432,53 +470,61 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let stream_options = get_stream_options(&app_state.app_config, StreamResponseMode::Stream);
|
||||
let session_state = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
|
||||
let pinned_provider = pinned_provider.filter(|provider| {
|
||||
item_type.is_live()
|
||||
|| session_state
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.media_started.load(std::sync::atomic::Ordering::Acquire))
|
||||
|| app_state.active_provider.should_reuse_playback_provider(session_token, provider)
|
||||
});
|
||||
let provider_session_headers =
|
||||
session_state.as_ref().and_then(|session| session.provider_session_headers_for(stream_url));
|
||||
let mut stream_details = match create_stream_response_details(
|
||||
app_state,
|
||||
&stream_options,
|
||||
stream_url,
|
||||
&user.username,
|
||||
fingerprint,
|
||||
req_headers,
|
||||
input,
|
||||
&stream_channel,
|
||||
item_type,
|
||||
if item_type == PlaylistItemType::Catchup {
|
||||
crate::api::model::ProviderContentRepresentationMode::Identity
|
||||
} else {
|
||||
crate::api::model::ProviderContentRepresentationMode::PreserveOrigin
|
||||
},
|
||||
share_stream,
|
||||
connection_permission,
|
||||
pinned_provider,
|
||||
pinned_provider.is_none() || allows_provider_pool_failover(item_type),
|
||||
true,
|
||||
VirtualId::new(stream_channel.virtual_id),
|
||||
connection_priority_for_kind(user, connection_kind),
|
||||
connection_kind,
|
||||
false,
|
||||
Some(session_token),
|
||||
provider_session_headers.as_deref(),
|
||||
pinned_provider.is_some(),
|
||||
grace_mode.map(|m| matches!(m, crate::api::model::GraceMode::Hold)),
|
||||
activation.grace_context.clone(),
|
||||
None,
|
||||
preacquired_provider_handle,
|
||||
)
|
||||
.await
|
||||
if matches!(&source, PlaybackSource::Group { .. })
|
||||
&& connection_permission == UserConnectionPermission::GracePeriod
|
||||
&& matches!(grace_mode, Some(crate::api::model::GraceMode::Hold))
|
||||
{
|
||||
Ok(stream_details) => stream_details,
|
||||
Err(err) => {
|
||||
let grace_period = app_state.get_grace_options();
|
||||
let hold_duration = Duration::from_millis(grace_period.period_millis);
|
||||
let deadline = tokio::time::Instant::now() + hold_duration;
|
||||
let mut passed = false;
|
||||
loop {
|
||||
if app_state.active_users.user_connections(&user.username).await < user.max_connections {
|
||||
passed = true;
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
if !passed {
|
||||
if let Some(ref ctx) = activation.grace_context {
|
||||
let eviction_guard = if socket_bound {
|
||||
tuliprox_session::admission::EvictionReentryGuard::SocketPlayback {
|
||||
virtual_id: shared::model::VirtualId(virtual_id),
|
||||
}
|
||||
} else {
|
||||
tuliprox_session::admission::EvictionReentryGuard::Session(session_token)
|
||||
};
|
||||
let remaining_result = tuliprox_session::admission::evaluate_remaining_strategies_after_grace(
|
||||
&app_state.admission_ctx(),
|
||||
tuliprox_session::admission::AdmissionRequest {
|
||||
username: &user.username,
|
||||
max_connections: user.max_connections,
|
||||
soft_connections: user.soft_connections,
|
||||
client_ip: &fingerprint.client_ip,
|
||||
request_addr: &fingerprint.addr,
|
||||
use_session_admission: true,
|
||||
session_token: Some(session_token),
|
||||
activate_unbound_session: true,
|
||||
eviction_reentry_guard: eviction_guard,
|
||||
},
|
||||
ctx,
|
||||
activation.admission.kind(),
|
||||
)
|
||||
.await;
|
||||
if matches!(
|
||||
remaining_result.admission.permission(),
|
||||
UserConnectionPermission::Allowed | UserConnectionPermission::GracePeriod
|
||||
) {
|
||||
passed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !passed {
|
||||
app_state
|
||||
.active_users
|
||||
.release_unbound_session_reservation(
|
||||
@@ -488,8 +534,457 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
activation.placeholder_transition_version.is_some(),
|
||||
)
|
||||
.await;
|
||||
error!("Failed to stream: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
record_connect_failed_attempt(ConnectFailedAttempt {
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
stream_channel: stream_channel.clone(),
|
||||
provider_name: initial_input.name.clone(),
|
||||
req_headers,
|
||||
reason: ConnectFailureReason::UserConnectionsExhausted,
|
||||
failure_stage: FailureStage::Admission,
|
||||
});
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.provider_stream_ctx(),
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::UserConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
connection_permission = UserConnectionPermission::Allowed;
|
||||
}
|
||||
|
||||
let stream_options = get_stream_options(&app_state.app_config, StreamResponseMode::Stream);
|
||||
|
||||
let (mut stream_details, input, stream_url, mut stream_channel) = match source {
|
||||
PlaybackSource::Single { input, stream_url } => {
|
||||
let session_state = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
|
||||
let pinned_provider = pinned_provider.filter(|provider| {
|
||||
item_type.is_live()
|
||||
|| session_state
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.media_started.load(std::sync::atomic::Ordering::Acquire))
|
||||
|| app_state.active_provider.should_reuse_playback_provider(session_token, provider)
|
||||
});
|
||||
let provider_session_headers =
|
||||
session_state.as_ref().and_then(|session| session.provider_session_headers_for(stream_url));
|
||||
let details = match create_stream_response_details(
|
||||
app_state,
|
||||
&stream_options,
|
||||
stream_url,
|
||||
&user.username,
|
||||
fingerprint,
|
||||
req_headers,
|
||||
input,
|
||||
&stream_channel,
|
||||
item_type,
|
||||
if item_type == PlaylistItemType::Catchup {
|
||||
crate::api::model::ProviderContentRepresentationMode::Identity
|
||||
} else {
|
||||
crate::api::model::ProviderContentRepresentationMode::PreserveOrigin
|
||||
},
|
||||
share_stream,
|
||||
connection_permission,
|
||||
pinned_provider,
|
||||
pinned_provider.is_none() || allows_provider_pool_failover(item_type),
|
||||
true,
|
||||
VirtualId::new(stream_channel.virtual_id),
|
||||
connection_priority_for_kind(user, connection_kind),
|
||||
connection_kind,
|
||||
false,
|
||||
Some(session_token),
|
||||
provider_session_headers.as_deref(),
|
||||
pinned_provider.is_some(),
|
||||
grace_mode.map(|m| matches!(m, crate::api::model::GraceMode::Hold)),
|
||||
activation.grace_context.clone(),
|
||||
None,
|
||||
preacquired_provider_handle,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(stream_details) => stream_details,
|
||||
Err(err) => {
|
||||
app_state
|
||||
.active_users
|
||||
.release_unbound_session_reservation(
|
||||
&user.username,
|
||||
session_token,
|
||||
activation.placeholder_transition_version,
|
||||
activation.placeholder_transition_version.is_some(),
|
||||
)
|
||||
.await;
|
||||
error!("Failed to stream: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
(details, Arc::clone(input), Arc::<str>::from(stream_url), stream_channel)
|
||||
}
|
||||
PlaybackSource::Group { group, mode, prefer } => {
|
||||
let (selection_cfg, allow_provider_grace) =
|
||||
target.options.as_ref().and_then(|o| o.channel_groups.as_ref()).map_or_else(
|
||||
|| (shared::model::ChannelGroupSelectionConfig::default(), true),
|
||||
|cg| (cg.selection, cg.allow_provider_grace),
|
||||
);
|
||||
let policy = group.policy;
|
||||
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
let mut exclude: Vec<usize> = Vec::new();
|
||||
let mut demoted_inputs: Vec<Arc<str>> = Vec::new();
|
||||
let mut attempts: u32 = 0;
|
||||
let max_attempts = u32::from(selection_cfg.max_start_attempts.max(1));
|
||||
let start_time = std::time::Instant::now();
|
||||
let budget = Duration::from_secs(selection_cfg.startup_budget_secs);
|
||||
|
||||
let mut final_details: Option<(StreamDetails, Arc<ConfigInput>, Arc<str>, StreamChannel)> = None;
|
||||
|
||||
while attempts < max_attempts && (selection_cfg.startup_budget_secs == 0 || start_time.elapsed() < budget) {
|
||||
attempts += 1;
|
||||
|
||||
let req = GroupSelectionRequest {
|
||||
target_name: &target_name,
|
||||
group: &group,
|
||||
policy,
|
||||
mode: mode.clone(),
|
||||
selection: selection_cfg,
|
||||
addr: &fingerprint.addr,
|
||||
username: &user.username,
|
||||
priority: connection_priority_for_kind(user, connection_kind),
|
||||
kind: connection_kind,
|
||||
lease: None,
|
||||
allow_provider_grace,
|
||||
exclude: &exclude,
|
||||
demoted_inputs: &demoted_inputs,
|
||||
prefer: if attempts == 1 { prefer } else { None },
|
||||
};
|
||||
|
||||
let acquired = match acquire_group_member(app_state, req).await {
|
||||
Ok(acq) => acq,
|
||||
Err(GroupSelectionFailure::Exhausted | GroupSelectionFailure::NoCandidate) => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let member = group.members[acquired.index].clone();
|
||||
let Some(member_input) = app_state.app_config.get_input_by_name(&member.input_name) else {
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
drop(acquired.handle);
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut member_channel = stream_channel.clone();
|
||||
member_channel.virtual_id = member.member_vid.get();
|
||||
member_channel.group_virtual_id = Some(group.group_vid.get());
|
||||
member_channel.input_name = Arc::clone(&member.input_name);
|
||||
member_channel.item_type = member.item_type;
|
||||
member_channel.cluster = member.cluster;
|
||||
member_channel.title = Arc::clone(&member.title);
|
||||
member_channel.url = Arc::clone(&member.url);
|
||||
member_channel.provider_id = member.provider_id;
|
||||
|
||||
let member_url = if member_input.input_type.is_stalker() {
|
||||
let Ok(url) = resolve_initial_stalker_playback_url(
|
||||
app_state,
|
||||
&member_input,
|
||||
user,
|
||||
member.provider_id,
|
||||
member.cluster,
|
||||
member.item_type,
|
||||
&member.url,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
drop(acquired.handle);
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
};
|
||||
url
|
||||
} else {
|
||||
Arc::clone(&member.url)
|
||||
};
|
||||
|
||||
let details_result = create_stream_response_details(
|
||||
app_state,
|
||||
&stream_options,
|
||||
member_url.as_ref(),
|
||||
&user.username,
|
||||
fingerprint,
|
||||
req_headers,
|
||||
&member_input,
|
||||
&member_channel,
|
||||
member.item_type,
|
||||
if member.item_type == PlaylistItemType::Catchup {
|
||||
crate::api::model::ProviderContentRepresentationMode::Identity
|
||||
} else {
|
||||
crate::api::model::ProviderContentRepresentationMode::PreserveOrigin
|
||||
},
|
||||
share_stream,
|
||||
connection_permission,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
VirtualId::new(member.member_vid.get()),
|
||||
connection_priority_for_kind(user, connection_kind),
|
||||
connection_kind,
|
||||
false,
|
||||
Some(session_token),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(acquired.handle),
|
||||
)
|
||||
.await;
|
||||
|
||||
let Ok(mut details) = details_result else {
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(mut provider_stream) = details.stream.take() {
|
||||
if let Some((_, _, _, Some(_custom_type))) = details.stream_info {
|
||||
let failure_class =
|
||||
if details.custom_reason == Some(ProviderStreamCustomReason::ProviderExhausted) {
|
||||
FailureClass::Capacity
|
||||
} else {
|
||||
FailureClass::Stream
|
||||
};
|
||||
if matches!(failure_class, FailureClass::Auth | FailureClass::Capacity) {
|
||||
demoted_inputs.push(Arc::clone(&member.input_name));
|
||||
}
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
failure_class,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
failure_class,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
app_state.connection_manager.release_managed_provider_handle(details.provider_handle);
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_adaptive_or_playlist = {
|
||||
if member.item_type.is_live_adaptive() {
|
||||
true
|
||||
} else if let Some((headers, _, _, _)) = &details.stream_info {
|
||||
headers.iter().any(|(name, val)| {
|
||||
name.as_str().eq_ignore_ascii_case("content-type") && {
|
||||
let lower = val.to_ascii_lowercase();
|
||||
lower.contains("mpegurl") || lower.contains("dash+xml")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut sync_ok = true;
|
||||
let mut buffered_chunks = Vec::new();
|
||||
|
||||
if !is_adaptive_or_playlist {
|
||||
let mut total_bytes = 0;
|
||||
let mut read_failed = false;
|
||||
let first_byte_timeout = Duration::from_secs(3);
|
||||
|
||||
while total_bytes < 564 {
|
||||
if let Ok(Some(Ok(chunk))) =
|
||||
tokio::time::timeout(first_byte_timeout, provider_stream.next()).await
|
||||
{
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total_bytes += chunk.len();
|
||||
buffered_chunks.push(chunk);
|
||||
} else {
|
||||
read_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sync_ok = if read_failed && buffered_chunks.is_empty() {
|
||||
false
|
||||
} else {
|
||||
let mut flat = Vec::with_capacity(total_bytes.min(564));
|
||||
for c in &buffered_chunks {
|
||||
let need = 564_usize.saturating_sub(flat.len());
|
||||
flat.extend_from_slice(&c[..c.len().min(need)]);
|
||||
if flat.len() >= 564 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tuliprox_mpegts::ts_sync_offset(&flat).is_some()
|
||||
};
|
||||
}
|
||||
|
||||
if !sync_ok {
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
app_state.connection_manager.release_managed_provider_handle(details.provider_handle);
|
||||
exclude.push(acquired.index);
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_success_with(guard);
|
||||
} else {
|
||||
app_state.member_health.record_success(&key);
|
||||
}
|
||||
|
||||
app_state.event_manager.send_event(EventMessage::ChannelGroupMemberSelected {
|
||||
target_id: target.id,
|
||||
group_vid: group.group_vid.get(),
|
||||
member_vid: member.member_vid.get(),
|
||||
tier: acquired.tier,
|
||||
});
|
||||
|
||||
app_state
|
||||
.active_users
|
||||
.bind_member(
|
||||
&user.username,
|
||||
session_token,
|
||||
tuliprox_session::MemberBinding {
|
||||
group_vid: group.group_vid.get(),
|
||||
member_vid: member.member_vid.get(),
|
||||
member_index: acquired.index,
|
||||
input_name: Arc::clone(&member.input_name),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if buffered_chunks.is_empty() {
|
||||
details.stream = Some(provider_stream);
|
||||
} else {
|
||||
let prepended =
|
||||
futures::stream::iter(buffered_chunks.into_iter().map(Ok)).chain(provider_stream);
|
||||
details.stream = Some(prepended.boxed());
|
||||
}
|
||||
|
||||
final_details = Some((details, member_input, member_url, member_channel));
|
||||
break;
|
||||
}
|
||||
|
||||
let failure_class = if let Some((_, status, _, _)) = &details.stream_info {
|
||||
FailureClass::from_status(*status)
|
||||
} else if details.custom_reason == Some(ProviderStreamCustomReason::ProviderExhausted) {
|
||||
FailureClass::Capacity
|
||||
} else {
|
||||
FailureClass::Stream
|
||||
};
|
||||
|
||||
if matches!(failure_class, FailureClass::Auth | FailureClass::Capacity) {
|
||||
demoted_inputs.push(Arc::clone(&member.input_name));
|
||||
}
|
||||
|
||||
let key = member_key(&member);
|
||||
if let Some(guard) = acquired.half_open {
|
||||
app_state.member_health.record_classified_failure_with(
|
||||
guard,
|
||||
failure_class,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
} else {
|
||||
app_state.member_health.record_classified_failure(&key, failure_class, std::time::Instant::now());
|
||||
}
|
||||
|
||||
app_state.connection_manager.release_managed_provider_handle(details.provider_handle);
|
||||
exclude.push(acquired.index);
|
||||
}
|
||||
|
||||
let Some((details, inp, url, ch)) = final_details else {
|
||||
app_state.event_manager.send_event(EventMessage::ChannelGroupExhausted {
|
||||
target_id: target.id,
|
||||
group_vid: group.group_vid.get(),
|
||||
});
|
||||
app_state
|
||||
.active_users
|
||||
.release_unbound_session_reservation(
|
||||
&user.username,
|
||||
session_token,
|
||||
activation.placeholder_transition_version,
|
||||
activation.placeholder_transition_version.is_some(),
|
||||
)
|
||||
.await;
|
||||
record_connect_failed_attempt(ConnectFailedAttempt {
|
||||
app_state,
|
||||
fingerprint,
|
||||
user,
|
||||
stream_channel: stream_channel.clone(),
|
||||
provider_name: initial_input.name.clone(),
|
||||
req_headers,
|
||||
reason: ConnectFailureReason::ProviderConnectionsExhausted,
|
||||
failure_stage: FailureStage::ProviderOpen,
|
||||
});
|
||||
return create_custom_video_stream_response(
|
||||
&app_state.provider_stream_ctx(),
|
||||
&fingerprint.addr,
|
||||
CustomVideoStreamType::ProviderConnectionsExhausted,
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
(details, inp, url, ch)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -505,13 +1000,13 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
manifest,
|
||||
user,
|
||||
target,
|
||||
input,
|
||||
input: &input,
|
||||
fingerprint,
|
||||
session_token,
|
||||
virtual_id: VirtualId::new(virtual_id),
|
||||
connection_permission,
|
||||
connection_kind,
|
||||
fallback_stream_url: stream_url,
|
||||
fallback_stream_url: &stream_url,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -539,8 +1034,9 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
.as_ref()
|
||||
.map(|(h, sc, response_url, cvt)| (h.clone(), *sc, response_url.clone(), *cvt));
|
||||
let provider_name = stream_details.provider_name.clone();
|
||||
let actual_request_url = stream_details.request_url.clone().unwrap_or_else(|| Arc::<str>::from(stream_url));
|
||||
let log_actual_request_url = resolve_request_url_for_logging(input, actual_request_url.as_ref());
|
||||
let actual_request_url =
|
||||
stream_details.request_url.clone().unwrap_or_else(|| Arc::<str>::from(stream_url.as_ref()));
|
||||
let log_actual_request_url = resolve_request_url_for_logging(&input, actual_request_url.as_ref());
|
||||
|
||||
debug_if_enabled!(
|
||||
"Provider request mapping: allocated_provider={} actual_request_url={}",
|
||||
@@ -603,7 +1099,7 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
};
|
||||
let mut metering = prepare_stream_metering(
|
||||
app_state,
|
||||
stream_url,
|
||||
if is_stream_shared { shared_lookup_key.as_ref() } else { stream_url.as_ref() },
|
||||
is_stream_shared,
|
||||
stream_details.stream.is_some(),
|
||||
stream_details.has_deferred_provider_open(),
|
||||
@@ -722,7 +1218,7 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
active_provider: &app_state.active_provider,
|
||||
connection_manager: &app_state.connection_manager,
|
||||
},
|
||||
stream_url,
|
||||
shared_lookup_key.as_ref(),
|
||||
stream,
|
||||
&fingerprint.addr,
|
||||
shared_subscriber_id,
|
||||
@@ -774,7 +1270,7 @@ pub(crate) async fn stream_response_with_provider_handle(
|
||||
.and_then(|(_, _, u, _)| u.as_ref())
|
||||
.map_or_else(|| Cow::Owned(actual_request_url.to_string()), |url| Cow::Owned(url.to_string()))
|
||||
};
|
||||
let log_session_url = resolve_request_url_for_logging(input, session_url.as_ref());
|
||||
let log_session_url = resolve_request_url_for_logging(&input, session_url.as_ref());
|
||||
if log_enabled!(log::Level::Debug) {
|
||||
if log_session_url.eq(log_actual_request_url.as_ref()) {
|
||||
debug!(
|
||||
|
||||
@@ -7,8 +7,9 @@ use super::{
|
||||
resolve_streaming_strategy_with_provider_handle, session_reacquire_cleanup_addrs,
|
||||
should_defer_provider_open_for_grace_hold, should_refresh_stalker_playback, split_provider_stream_open,
|
||||
stalker_stream_kind, stream_admission_rejected_response, stream_response_with_provider_handle, try_unwrap_body,
|
||||
ConnectFailedAttempt, CurrentSessionGuard, ForceStreamRequestContext, PlaybackRequestClass, RedirectParams,
|
||||
ResourceFetchPolicy, StreamMeteringConfig, StreamOptions, StreamResponseMode, HLS_MEDIA_CAPACITY_WAIT,
|
||||
ConnectFailedAttempt, CurrentSessionGuard, ForceStreamRequestContext, PlaybackRequestClass, PlaybackSource,
|
||||
RedirectParams, ResourceFetchPolicy, StreamMeteringConfig, StreamOptions, StreamResponseMode,
|
||||
HLS_MEDIA_CAPACITY_WAIT,
|
||||
};
|
||||
use crate::{
|
||||
api::{
|
||||
@@ -608,6 +609,24 @@ pub(super) fn no_custom_video_fallback_status(app_config: &AppConfig) -> StatusC
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sticky_playback_context(
|
||||
app_state: &Arc<AppState>,
|
||||
session: &UserSession,
|
||||
fallback_input: &Arc<ConfigInput>,
|
||||
fallback_channel: StreamChannel,
|
||||
) -> (Arc<ConfigInput>, StreamChannel) {
|
||||
if let Some(binding) = &session.member_binding {
|
||||
if let Some(member_input) = app_state.app_config.get_input_by_name(&binding.input_name) {
|
||||
let mut channel = fallback_channel;
|
||||
channel.virtual_id = binding.member_vid;
|
||||
channel.group_virtual_id = Some(binding.group_vid);
|
||||
channel.input_name = Arc::clone(&binding.input_name);
|
||||
return (member_input, channel);
|
||||
}
|
||||
}
|
||||
(Arc::clone(fallback_input), fallback_channel)
|
||||
}
|
||||
|
||||
pub async fn force_provider_stream_response(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &Arc<AppState>,
|
||||
@@ -657,11 +676,13 @@ pub(super) async fn force_stream_response(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &Arc<AppState>,
|
||||
user_session: &UserSession,
|
||||
mut stream_channel: StreamChannel,
|
||||
stream_channel: StreamChannel,
|
||||
ctx: ForceStreamRequestContext<'_>,
|
||||
grace_mode: Option<crate::api::model::GraceMode>,
|
||||
response_mode: StreamResponseMode,
|
||||
) -> axum::response::Response {
|
||||
let (sticky_input, mut stream_channel) =
|
||||
sticky_playback_context(app_state, user_session, ctx.input, stream_channel);
|
||||
let hls_resource = response_mode == StreamResponseMode::HlsResource;
|
||||
// Finite HLS resources stay pinned to the session account and never rebind or clean up
|
||||
// other sockets, so they skip the session transition gate. Holding it across the
|
||||
@@ -739,7 +760,7 @@ pub(super) async fn force_stream_response(
|
||||
&ctx.user.username,
|
||||
fingerprint,
|
||||
ctx.req_headers,
|
||||
ctx.input,
|
||||
&sticky_input,
|
||||
&stream_channel,
|
||||
item_type,
|
||||
ctx.content_representation,
|
||||
@@ -952,7 +973,7 @@ pub(super) async fn force_stream_response(
|
||||
debug_if_enabled!(
|
||||
"Streaming provider forced stream request from {}",
|
||||
sanitize_sensitive_info(
|
||||
resolve_request_url_for_logging(ctx.input, user_session.stream_url.as_ref()).as_ref()
|
||||
resolve_request_url_for_logging(&sticky_input, user_session.stream_url.as_ref()).as_ref()
|
||||
)
|
||||
);
|
||||
let mut response = try_unwrap_body!(response.body(body_stream));
|
||||
@@ -990,10 +1011,9 @@ pub(crate) async fn stream_response(
|
||||
session_token: &str,
|
||||
request_class: Option<PlaybackRequestClass>,
|
||||
stream_channel: StreamChannel,
|
||||
stream_url: &str,
|
||||
source: PlaybackSource<'_>,
|
||||
pinned_provider: Option<&Arc<str>>,
|
||||
req_headers: &HeaderMap,
|
||||
input: &Arc<ConfigInput>,
|
||||
target: &Arc<ConfigTarget>,
|
||||
user: &ProxyUserCredentials,
|
||||
connection_permission: UserConnectionPermission,
|
||||
@@ -1007,10 +1027,9 @@ pub(crate) async fn stream_response(
|
||||
session_token,
|
||||
request_class,
|
||||
stream_channel,
|
||||
stream_url,
|
||||
source,
|
||||
pinned_provider,
|
||||
req_headers,
|
||||
input,
|
||||
target,
|
||||
user,
|
||||
connection_permission,
|
||||
|
||||
@@ -37,3 +37,8 @@ mod stream_responses_provider_capacity;
|
||||
mod stream_responses_provider_fallback;
|
||||
mod stream_responses_response_headers;
|
||||
mod stream_responses_session_activation;
|
||||
|
||||
mod channel_group_playback;
|
||||
mod channel_group_selection;
|
||||
mod channel_group_sticky;
|
||||
pub(crate) mod group_playback_support;
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::{
|
||||
create_test_provider_app_config, create_test_shared_target, load_test_channel, load_test_user,
|
||||
resolve_admission_with_strategies, should_allow_exhausted_shared_reconnect,
|
||||
should_defer_provider_open_for_grace_hold, spawn_load_test_origin, stream_admission_rejected_response,
|
||||
stream_response, AdmissionRequest, EvictionReentryGuard, SessionActivationRequest,
|
||||
stream_response, AdmissionRequest, EvictionReentryGuard, PlaybackSource, SessionActivationRequest,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{CustomVideoStreamType, UserSession},
|
||||
@@ -99,10 +99,9 @@ async fn shared_admission_rejection_does_not_leak_meter_registration() {
|
||||
"shared-session-adm",
|
||||
None,
|
||||
channel,
|
||||
&stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
UserConnectionPermission::Allowed,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::group_playback_support::{ts_fixture, GroupPlaybackEnv};
|
||||
use shared::model::AdmissionStrategy;
|
||||
use tuliprox_session::channel_group::CircuitView;
|
||||
|
||||
#[tokio::test]
|
||||
async fn xtream_group_playback_uses_member_input() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"])
|
||||
.with_qos("korath", 2, 95)
|
||||
.with_qos("solis", 1, 20)
|
||||
.build()
|
||||
.await;
|
||||
let response = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_failover_moves_to_next_member_on_open_failure() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).origin_status("solis", 503).build().await;
|
||||
let response = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert!(matches!(env.circuit("solis"), CircuitView::Counting(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_rejection_moves_to_other_input_without_circuit() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).origin_status("solis", 429).build().await;
|
||||
let response = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert!(matches!(env.circuit("solis"), CircuitView::Closed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_ts_first_chunk_fails_over() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"])
|
||||
.origin_body("solis", "video/mp2t", b"Channel offline, please try later".to_vec())
|
||||
.build()
|
||||
.await;
|
||||
assert_eq!(env.get_xtream_live(env.group_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert!(matches!(env.circuit("solis"), CircuitView::Counting(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts_with_leading_garbage_is_accepted() {
|
||||
let mut body = vec![0u8; 17];
|
||||
body.extend(ts_fixture());
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).origin_body("solis", "video/mp2t", body).build().await;
|
||||
assert_eq!(env.get_xtream_live(env.group_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_failure_counts_for_member_and_input() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).origin_status("solis", 401).build().await;
|
||||
assert_eq!(env.get_xtream_live(env.group_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert!(matches!(env.circuit("solis"), CircuitView::Counting(1)));
|
||||
assert!(matches!(env.input_circuit("solis"), CircuitView::Closed)); // one member only, input stays closed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_vid_request_plays_group_preferring_that_member() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_qos("korath", 2, 95).build().await;
|
||||
let response = env.get_xtream_live(env.member_vid("solis")).await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!((env.origin_hits("solis"), env.origin_hits("korath")), (1, 0)); // preferred despite lower QoS
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favourite_copy_plays_as_group() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_favourite().with_qos("korath", 2, 95).build().await;
|
||||
assert_eq!(env.get_xtream_live(env.favourite_vid()).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_group_playback_is_unchanged() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await;
|
||||
assert_eq!(env.get_xtream_live(env.plain_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("solis"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn m3u_group_playback_uses_member_input() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_qos("korath", 2, 95).build().await;
|
||||
assert_eq!(env.get_m3u_live(env.group_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalker_member_url_resolved_after_selection() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).stalker("solis").with_qos("korath", 2, 95).build().await;
|
||||
assert_eq!(env.get_xtream_live(env.group_vid).await.status(), 200);
|
||||
assert_eq!(env.origin_hits("solis"), 0); // no Stalker create_link against the representative
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_uses_group_vid_for_eviction() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"])
|
||||
.user_limit(1)
|
||||
.admission(vec![AdmissionStrategy::EvictUserSameIpOldest])
|
||||
.build()
|
||||
.await;
|
||||
let first = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(first.status(), 200);
|
||||
let second = env.get_xtream_live(env.plain_vid).await;
|
||||
assert_eq!(second.status(), 200);
|
||||
assert_eq!(env.active_session_vids("viewer").await, vec![env.plain_vid]); // group session evicted by its group vid
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grace_hold_selects_member_only_after_hold_passes() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"])
|
||||
.user_limit(1)
|
||||
.admission(vec![AdmissionStrategy::GraceHoldStream])
|
||||
.build()
|
||||
.await;
|
||||
let _first = env.get_xtream_live(env.plain_vid).await;
|
||||
let slots_before = env.slots_in_use();
|
||||
let rejected = env.get_xtream_live(env.group_vid).await; // first stream still active → hold fails
|
||||
assert_ne!(rejected.status(), 200);
|
||||
assert_eq!(env.slots_in_use(), slots_before); // no member slot was taken during the hold
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::{
|
||||
super::channel_group::{
|
||||
acquire_group_member, acquire_group_member_with_race, GroupSelectionFailure, GroupSelectionRequest,
|
||||
},
|
||||
support::{create_test_app_config, create_test_app_state_for_config},
|
||||
};
|
||||
use crate::api::model::AppState;
|
||||
use arc_swap::ArcSwap;
|
||||
use shared::{
|
||||
model::{
|
||||
ChannelGroupPolicy, ChannelGroupsConfig, InputType, PlaylistItemType, ReverseProxyConfigDto, StreamConfigDto,
|
||||
VirtualId, XtreamCluster,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tuliprox_core::model::{Config, ConfigInput, ProviderHandle, ReverseProxyConfig, SourcesConfig};
|
||||
use tuliprox_repository::{
|
||||
channel_group_repository::{ChannelGroupRecord, GroupMember},
|
||||
ChannelGroupOrigin,
|
||||
};
|
||||
use tuliprox_session::{
|
||||
active_provider_manager::{CapacityTier, ConnectionKind},
|
||||
channel_group::SelectionMode,
|
||||
};
|
||||
|
||||
static CLIENT_ADDR: std::sync::LazyLock<SocketAddr> =
|
||||
std::sync::LazyLock::new(|| "127.0.0.1:40000".parse().expect("valid client addr"));
|
||||
|
||||
fn addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) }
|
||||
|
||||
fn xtream_input(id: u16, name: &str, max_connections: u16) -> Arc<ConfigInput> {
|
||||
Arc::new(ConfigInput {
|
||||
id,
|
||||
name: name.intern(),
|
||||
input_type: InputType::Xtream,
|
||||
url: format!("http://{name}.invalid"),
|
||||
username: Some("user".into()),
|
||||
password: Some("pass".into()),
|
||||
enabled: true,
|
||||
max_connections,
|
||||
..ConfigInput::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn group_member(input: &str, stream_id: &str, provider_id: u32, member_vid: u32) -> GroupMember {
|
||||
GroupMember {
|
||||
member_vid: VirtualId::new(member_vid),
|
||||
input_name: input.into(),
|
||||
provider_id,
|
||||
input_stream_id: stream_id.into(),
|
||||
url: format!("http://{input}.invalid/{stream_id}.ts").into(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
cluster: XtreamCluster::Live,
|
||||
title: format!("{input} stream").into(),
|
||||
archive: None,
|
||||
epg_channel_id: None,
|
||||
additional_properties: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn app_config_with_sources(sources: SourcesConfig) -> tuliprox_core::model::AppConfig {
|
||||
let mut config = Config::default();
|
||||
let dto = ReverseProxyConfigDto {
|
||||
stream: Some(StreamConfigDto { grace_period_millis: 2_000, ..StreamConfigDto::default() }),
|
||||
..ReverseProxyConfigDto::default()
|
||||
};
|
||||
config.reverse_proxy = Some(ReverseProxyConfig::from(&dto));
|
||||
let mut app_config = create_test_app_config();
|
||||
app_config.config = Arc::new(ArcSwap::from_pointee(config));
|
||||
app_config.sources = Arc::new(ArcSwap::from_pointee(sources));
|
||||
app_config
|
||||
}
|
||||
|
||||
struct SelectionEnv {
|
||||
app_state: Arc<AppState>,
|
||||
group: ChannelGroupRecord,
|
||||
config: ChannelGroupsConfig,
|
||||
target: Arc<str>,
|
||||
}
|
||||
|
||||
impl SelectionEnv {
|
||||
fn new(members: &[(&str, u16)]) -> Self {
|
||||
let sources = SourcesConfig {
|
||||
inputs: members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, max))| xtream_input(u16::try_from(i + 1).expect("id"), name, *max))
|
||||
.collect(),
|
||||
..SourcesConfig::default()
|
||||
};
|
||||
let app_state = create_test_app_state_for_config(Arc::new(app_config_with_sources(sources)));
|
||||
let group = ChannelGroupRecord {
|
||||
group_vid: VirtualId::new(1),
|
||||
key: "zdf".into(),
|
||||
origin: ChannelGroupOrigin::Auto,
|
||||
policy: ChannelGroupPolicy::Priority,
|
||||
orphaned: Vec::new(),
|
||||
members: members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, _))| {
|
||||
group_member(
|
||||
name,
|
||||
&(i + 1).to_string(),
|
||||
u32::try_from(i + 1).expect("provider_id"),
|
||||
100 + u32::try_from(i).expect("member_vid"),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
Self {
|
||||
app_state,
|
||||
group,
|
||||
config: serde_json::from_str(r#"{"enabled":true}"#).expect("config"),
|
||||
target: "Main".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hold(&self, input: &str, priority: i8) -> ProviderHandle {
|
||||
self.app_state
|
||||
.active_provider
|
||||
.acquire_connection(&input.into(), &addr(200), priority, ConnectionKind::Normal)
|
||||
.expect("hold slot")
|
||||
}
|
||||
|
||||
fn request<'a>(&'a self, exclude: &'a [usize], allow_grace: bool) -> GroupSelectionRequest<'a> {
|
||||
GroupSelectionRequest {
|
||||
target_name: &self.target,
|
||||
group: &self.group,
|
||||
policy: self.group.policy,
|
||||
mode: SelectionMode::Live,
|
||||
selection: self.config.selection,
|
||||
addr: &CLIENT_ADDR,
|
||||
username: "viewer",
|
||||
priority: 0,
|
||||
kind: ConnectionKind::Normal,
|
||||
lease: None,
|
||||
allow_provider_grace: allow_grace,
|
||||
exclude,
|
||||
demoted_inputs: &[],
|
||||
prefer: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slots_in_use(&self) -> usize {
|
||||
self.app_state.active_provider.active_connections().unwrap_or_default().values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn free_member_is_preferred_over_better_member_needing_grace() {
|
||||
let env = SelectionEnv::new(&[("korath", 1), ("solis", 1)]);
|
||||
let _blocker = env.hold("korath", 0);
|
||||
let acquired = acquire_group_member(&env.app_state, env.request(&[], true)).await.expect("acquired");
|
||||
assert_eq!((acquired.index, acquired.tier), (1, CapacityTier::Free));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lost_race_on_free_reprobes_free_before_grace() {
|
||||
let env = SelectionEnv::new(&[("solis", 1), ("korath", 1), ("veyra", 1)]);
|
||||
let _veyra_full = env.hold("veyra", 0);
|
||||
let acquired = acquire_group_member_with_race(&env.app_state, env.request(&[], true), |state| {
|
||||
let handle = state.active_provider.acquire_connection(&"solis".into(), &addr(201), 0, ConnectionKind::Normal);
|
||||
std::mem::forget(handle);
|
||||
})
|
||||
.await
|
||||
.expect("acquired");
|
||||
assert_eq!((acquired.index, acquired.tier), (1, CapacityTier::Free));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn own_release_keeps_best_member_when_zapping() {
|
||||
let env = SelectionEnv::new(&[("korath", 1), ("solis", 1)]);
|
||||
let own = env.hold("korath", 0);
|
||||
env.app_state.active_users.set_pending_release_for_test("viewer", &[own.allocation_id]).await;
|
||||
let acquired = acquire_group_member(&env.app_state, env.request(&[], true)).await.expect("acquired");
|
||||
assert_eq!((acquired.index, acquired.tier), (0, CapacityTier::OwnRelease));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preemption_only_on_best_ranked_when_nothing_free() {
|
||||
let env = SelectionEnv::new(&[("korath", 1), ("solis", 1)]);
|
||||
let _a = env.hold("korath", 10);
|
||||
let _b = env.hold("solis", 10);
|
||||
let mut req = env.request(&[], false);
|
||||
req.priority = -5;
|
||||
let acquired = acquire_group_member(&env.app_state, req).await.expect("acquired");
|
||||
assert_eq!((acquired.index, acquired.tier), (0, CapacityTier::Preempt));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demoted_input_members_are_tried_last() {
|
||||
let env = SelectionEnv::new(&[("korath", 1), ("solis", 1)]);
|
||||
let demoted: [Arc<str>; 1] = ["korath".into()];
|
||||
let mut req = env.request(&[], true);
|
||||
req.demoted_inputs = &demoted;
|
||||
let acquired = acquire_group_member(&env.app_state, req).await.expect("acquired");
|
||||
assert_eq!((acquired.index, acquired.tier), (1, CapacityTier::Free));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grace_tier_skipped_when_disabled() {
|
||||
let env = SelectionEnv::new(&[("korath", 1)]);
|
||||
let _full = env.hold("korath", 0);
|
||||
let result = acquire_group_member(&env.app_state, env.request(&[], false)).await;
|
||||
assert!(matches!(result, Err(GroupSelectionFailure::Exhausted)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_members_exhausted_returns_exhausted_within_budget() {
|
||||
let mut env = SelectionEnv::new(&[("korath", 1), ("solis", 1)]);
|
||||
env.config.selection.startup_budget_secs = 1;
|
||||
let _a = env.hold("korath", 10);
|
||||
let _a_grace = env
|
||||
.app_state
|
||||
.active_provider
|
||||
.try_acquire_connection_no_preempt(&"korath".into(), true, &addr(201), 10, ConnectionKind::Normal, None)
|
||||
.expect("grace");
|
||||
let _b = env.hold("solis", 10);
|
||||
let _b_grace = env
|
||||
.app_state
|
||||
.active_provider
|
||||
.try_acquire_connection_no_preempt(&"solis".into(), true, &addr(202), 10, ConnectionKind::Normal, None)
|
||||
.expect("grace");
|
||||
let before = env.slots_in_use();
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut req = env.request(&[], true);
|
||||
req.priority = 10;
|
||||
let result = acquire_group_member(&env.app_state, req).await;
|
||||
assert!(matches!(result, Err(GroupSelectionFailure::Exhausted)));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
assert_eq!(env.slots_in_use(), before);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::group_playback_support::GroupPlaybackEnv;
|
||||
use axum::http::StatusCode;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_request_uses_bound_member_input_not_representative() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_qos("korath", 2, 95).build().await;
|
||||
let initial = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(initial.status(), StatusCode::OK);
|
||||
assert_eq!(env.bound_member_input("viewer").await, "korath");
|
||||
assert_eq!(env.origin_hits("korath"), 1);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
|
||||
let seek = env.get_xtream_live_with_range(env.group_vid, "bytes=1000-").await;
|
||||
assert!(seek.status().is_success());
|
||||
assert_eq!(env.bound_member_input("viewer").await, "korath");
|
||||
assert_eq!(env.origin_hits("korath"), 2);
|
||||
assert_eq!(env.origin_hits("solis"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts_reconnect_stays_on_member_unless_circuit_open() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_qos("korath", 2, 95).build().await;
|
||||
let first = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
drop(first);
|
||||
|
||||
// reconnect within session TTL stays on korath
|
||||
let second = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
drop(second);
|
||||
assert_eq!((env.origin_hits("korath"), env.origin_hits("solis")), (2, 0));
|
||||
assert_eq!(env.bound_member_input("viewer").await, "korath");
|
||||
|
||||
// Open circuit on korath -> sticky binding is broken by circuit breaker, fails over to solis
|
||||
env.open_circuit("korath");
|
||||
let third = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(third.status(), StatusCode::OK);
|
||||
drop(third);
|
||||
assert_eq!(env.origin_hits("korath"), 2);
|
||||
assert_eq!(env.origin_hits("solis"), 1);
|
||||
assert_eq!(env.bound_member_input("viewer").await, "solis");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binding_survives_group_removal_until_stream_end() {
|
||||
let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await;
|
||||
let response = env.get_xtream_live(env.group_vid).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(env.bound_member_input("viewer").await, "solis");
|
||||
assert!(env.active_session_vids("viewer").await.contains(&env.group_vid));
|
||||
|
||||
let mut body = response.into_body().into_data_stream();
|
||||
|
||||
// Republish target storage with groups removed while stream is actively running
|
||||
env.republish_without_groups().await;
|
||||
|
||||
// Running stream body continues delivering bytes despite group removal from storage
|
||||
let chunk = body.next().await.expect("stream continues").expect("bytes");
|
||||
assert!(!chunk.is_empty());
|
||||
assert_eq!(env.bound_member_input("viewer").await, "solis");
|
||||
|
||||
// New request for the removed group VID sees new state and does not panic (returns 502 Bad Gateway / channel unavailable)
|
||||
let after = env.get_xtream_live(env.group_vid).await;
|
||||
assert_ne!(after.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(after.status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
// After body finishes/is dropped, active session lease cleans up
|
||||
drop(body);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
create_test_provider_app_state, create_test_shared_target, force_provider_stream_response, get_stream_options,
|
||||
load_test_channel, load_test_session, load_test_user, session_reacquire_cleanup_addrs,
|
||||
spawn_controlled_fake_origin, spawn_range_aware_test_origin, stream_response, FakeOriginMode,
|
||||
ForceStreamRequestContext, PlaybackRequestClass, SessionActivationRequest, StreamResponseMode,
|
||||
ForceStreamRequestContext, PlaybackRequestClass, PlaybackSource, SessionActivationRequest, StreamResponseMode,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{create_channel_unavailable_stream, UserSession},
|
||||
@@ -1025,10 +1025,9 @@ async fn shared_proxy_clients_keep_independent_bodies_and_cleanup() {
|
||||
"shared-session-1",
|
||||
None,
|
||||
channel1,
|
||||
&stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user1,
|
||||
UserConnectionPermission::Allowed,
|
||||
@@ -1046,10 +1045,9 @@ async fn shared_proxy_clients_keep_independent_bodies_and_cleanup() {
|
||||
"shared-session-2",
|
||||
None,
|
||||
channel2,
|
||||
&stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user2,
|
||||
UserConnectionPermission::Allowed,
|
||||
@@ -1136,10 +1134,9 @@ async fn shared_start_abort_does_not_leak_meter_registration() {
|
||||
"shared-session-abort",
|
||||
None,
|
||||
channel,
|
||||
&stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url: &stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
UserConnectionPermission::Allowed,
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
use super::support::FakeOriginMode;
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::channel_group::{member_key, CircuitView},
|
||||
endpoints::{
|
||||
hdhomerun_api::hdhr_api_register, m3u_api::m3u_api_register, xmltv_api::xmltv_api_register,
|
||||
xtream_api::xtream_api_register,
|
||||
},
|
||||
model::{AppState, HdHomerunAppState},
|
||||
},
|
||||
model::{
|
||||
Config, ConfigInput, ConfigProvider, ConfigTarget, Epg, HdHomeRunDeviceConfig, HdHomeRunTargetOutput,
|
||||
M3uTargetOutput, ProxyUserCredentials, SourcesConfig, StreamConfig, TargetOutput, XtreamTargetOutput,
|
||||
},
|
||||
repository::{
|
||||
get_target_storage_path, persist_playlist_views_with_groups, ChannelGroupOrigin, ChannelGroupRecord,
|
||||
ChannelGroupStore, PendingChannelGroup, PlaylistStorageState, TargetPlaylistPersistOptions,
|
||||
},
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderMap, Request},
|
||||
response::Response,
|
||||
};
|
||||
use shared::{
|
||||
foundation::Filter,
|
||||
model::{
|
||||
AdmissionStrategy, ChannelGroupPolicy, ChannelGroupSelectionConfig, ChannelGroupsConfig, ClusterFlags,
|
||||
ConfigPaths, ConfigTargetOptions, HdHomeRunTargetOutputDto, InputType, LiveStreamProperties,
|
||||
M3uTargetOutputDto, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProcessingOrder, ProxyUserStatus,
|
||||
StreamProperties, TargetType, UUIDType, VirtualId, XtreamCluster, XtreamTargetOutputDto,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
str::FromStr,
|
||||
sync::{atomic::AtomicI8, Arc},
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub(super) fn ts_fixture() -> Vec<u8> {
|
||||
let mut data = vec![0u8; 188 * 4];
|
||||
for i in 0..4 {
|
||||
data[i * 188] = 0x47;
|
||||
data[i * 188 + 1] = 0x1f;
|
||||
data[i * 188 + 2] = 0xff;
|
||||
data[i * 188 + 3] = 0x10;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
pub(crate) struct TrackedOrigin {
|
||||
pub addr: SocketAddr,
|
||||
pub requests: Arc<std::sync::Mutex<Vec<String>>>,
|
||||
pub _task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn spawn_tracked_origin(mode: FakeOriginMode) -> TrackedOrigin {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
|
||||
let addr = listener.local_addr().expect("test origin address");
|
||||
let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let requests_clone = Arc::clone(&requests);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
for _ in 0..20 {
|
||||
let Ok((mut socket, _)) = listener.accept().await else { break };
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let Ok(read) = socket.read(&mut chunk).await else { break };
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
if request.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let request_str = String::from_utf8_lossy(&request).to_string();
|
||||
requests_clone.lock().expect("mutex").push(request_str.clone());
|
||||
|
||||
match &mode {
|
||||
FakeOriginMode::EmptyBody => {
|
||||
let head =
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::BlockFirstChunk(notify) => {
|
||||
let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let mut probe = [0_u8; 1];
|
||||
tokio::select! {
|
||||
() = notify.notified() => {},
|
||||
_ = socket.read(&mut probe) => {},
|
||||
}
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::ErrorBeforeFirstChunk => {
|
||||
let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::ErrorAfterFirstChunk(first_chunk) => {
|
||||
let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let chunk_header = format!("{:x}\r\n", first_chunk.len());
|
||||
let _ = socket.write_all(chunk_header.as_bytes()).await;
|
||||
let _ = socket.write_all(first_chunk).await;
|
||||
let _ = socket.write_all(b"\r\n").await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::FixedBytes(bytes) => {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
);
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(bytes).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Status(status) => {
|
||||
let head = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Body { content_type, bytes } => {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
);
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(bytes).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Hls => {
|
||||
if request_str.contains(".ts") {
|
||||
let bytes = ts_fixture();
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
);
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(&bytes).await;
|
||||
let _ = socket.shutdown().await;
|
||||
} else {
|
||||
let body = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:1\n#EXTINF:10.0,\nsegment1.ts\n#EXTINF:10.0,\nsegment2.ts\n";
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(body.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TrackedOrigin { addr, requests, _task: task }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn first_segment_url(manifest: &str) -> String {
|
||||
manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct GroupPlaybackEnv {
|
||||
pub app_state: Arc<AppState>,
|
||||
pub target: Arc<ConfigTarget>,
|
||||
pub group_vid: u32,
|
||||
pub plain_vid: u32,
|
||||
pub favourite_vid: u32,
|
||||
pub group_record: Arc<ChannelGroupRecord>,
|
||||
pub origins: HashMap<String, TrackedOrigin>,
|
||||
pub router: axum::Router,
|
||||
pub hdhr_router: Option<axum::Router>,
|
||||
pub _dir: TempDir,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl GroupPlaybackEnv {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(inputs: &[&str]) -> GroupPlaybackEnvBuilder { Self::builder(inputs) }
|
||||
|
||||
pub fn builder(inputs: &[&str]) -> GroupPlaybackEnvBuilder {
|
||||
GroupPlaybackEnvBuilder {
|
||||
inputs: inputs.iter().map(|s| (*s).to_string()).collect(),
|
||||
origin_modes: HashMap::new(),
|
||||
qos: Vec::new(),
|
||||
hls: false,
|
||||
stalker: None,
|
||||
user_limit: None,
|
||||
admission: None,
|
||||
favourite: false,
|
||||
plain_channel: true,
|
||||
member_archives: Vec::new(),
|
||||
epg: None,
|
||||
epg_channel_id: None,
|
||||
hdhomerun: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_xtream_live_streams(&self) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri("/player_api.php?username=viewer&password=secret&action=get_live_streams")
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_m3u_playlist(&self) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri("/get.php?username=viewer&password=secret&type=m3u_plus&output=ts")
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_hdhomerun_lineup(&self) -> Response {
|
||||
let Some(ref hdhr_router) = self.hdhr_router else {
|
||||
panic!("HDHomeRun router not initialized; did you call .with_hdhomerun()?");
|
||||
};
|
||||
let mut req = Request::builder().uri("/lineup.json").body(Body::empty()).expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
hdhr_router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_xmltv_epg(&self) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri("/xmltv.php?username=viewer&password=secret")
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn response_text(response: Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("read body");
|
||||
String::from_utf8(bytes.to_vec()).expect("utf-8 body")
|
||||
}
|
||||
|
||||
pub async fn get_xtream_live(&self, vid: u32) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri(format!("/live/viewer/secret/{vid}.ts"))
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_xtream_live_with_range(&self, vid: u32, range: &str) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri(format!("/live/viewer/secret/{vid}.ts"))
|
||||
.header("Range", range)
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_m3u_live(&self, vid: u32) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri(format!("/m3u-stream/live/viewer/secret/{vid}.ts"))
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub fn origin_hits(&self, input: &str) -> usize {
|
||||
self.origins.get(input).map_or(0, |o| o.requests.lock().expect("mutex").len())
|
||||
}
|
||||
|
||||
pub fn last_request_headers(&self, input: &str) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
let Some(origin) = self.origins.get(input) else { return map };
|
||||
let guard = origin.requests.lock().expect("mutex");
|
||||
let Some(last) = guard.last() else { return map };
|
||||
for line in last.lines().skip(1) {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, val)) = line.split_once(':') {
|
||||
if let (Ok(n), Ok(v)) =
|
||||
(axum::http::HeaderName::from_str(name.trim()), axum::http::HeaderValue::from_str(val.trim()))
|
||||
{
|
||||
map.insert(n, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub fn circuit(&self, input: &str) -> CircuitView {
|
||||
let member =
|
||||
self.group_record.members.iter().find(|m| m.input_name.as_ref() == input).expect("member for input");
|
||||
let key = member_key(member);
|
||||
self.app_state.member_health.circuit_view(&key, std::time::Instant::now())
|
||||
}
|
||||
|
||||
pub fn input_circuit(&self, input: &str) -> CircuitView {
|
||||
self.app_state.member_health.input_circuit_view(input, std::time::Instant::now())
|
||||
}
|
||||
|
||||
pub fn open_circuit(&self, input: &str) {
|
||||
let member =
|
||||
self.group_record.members.iter().find(|m| m.input_name.as_ref() == input).expect("member for input");
|
||||
let key = member_key(member);
|
||||
for _ in 0..10 {
|
||||
if matches!(self.circuit(input), CircuitView::Open { .. }) {
|
||||
break;
|
||||
}
|
||||
self.app_state.member_health.record_classified_failure(
|
||||
&key,
|
||||
tuliprox_session::channel_group::FailureClass::Stream,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn member_vid(&self, input: &str) -> u32 {
|
||||
let member =
|
||||
self.group_record.members.iter().find(|m| m.input_name.as_ref() == input).expect("member for input");
|
||||
member.member_vid.get()
|
||||
}
|
||||
|
||||
pub fn favourite_vid(&self) -> u32 { self.favourite_vid }
|
||||
|
||||
pub fn hold(&self, input: &str, priority: i8) -> Vec<tuliprox_core::model::provider::ProviderHandle> {
|
||||
let mut handles = Vec::new();
|
||||
while let Some(h) = self.app_state.active_provider.acquire_connection(
|
||||
&input.into(),
|
||||
&SocketAddr::from(([127, 0, 0, 1], 200)),
|
||||
priority,
|
||||
tuliprox_session::active_provider_manager::ConnectionKind::Normal,
|
||||
) {
|
||||
handles.push(h);
|
||||
}
|
||||
handles
|
||||
}
|
||||
|
||||
pub async fn get_hls_manifest(&self, vid: u32) -> Response {
|
||||
let mut req = Request::builder()
|
||||
.uri(format!("/live/viewer/secret/{vid}.m3u8"))
|
||||
.body(Body::empty())
|
||||
.expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn get_hls_manifest_text(&self, vid: u32) -> String {
|
||||
let response = self.get_hls_manifest(vid).await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024).await.expect("bytes");
|
||||
String::from_utf8(bytes.to_vec()).expect("utf-8 manifest")
|
||||
}
|
||||
|
||||
pub async fn refresh_hls_manifest(&self, master_text: &str) -> Response {
|
||||
let uri = first_segment_url(master_text);
|
||||
self.get(&uri).await
|
||||
}
|
||||
|
||||
pub async fn get(&self, uri: &str) -> Response {
|
||||
let path = if let Ok(parsed) = url::Url::parse(uri) {
|
||||
let mut p = parsed.path().to_string();
|
||||
if let Some(stripped) = p.strip_prefix("/iptv") {
|
||||
p = stripped.to_string();
|
||||
}
|
||||
if let Some(q) = parsed.query() {
|
||||
p.push('?');
|
||||
p.push_str(q);
|
||||
}
|
||||
p
|
||||
} else if let Some(stripped) = uri.strip_prefix("/iptv") {
|
||||
stripped.to_string()
|
||||
} else {
|
||||
uri.to_string()
|
||||
};
|
||||
let mut req = Request::builder().uri(path).body(Body::empty()).expect("request builder");
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
self.router.clone().oneshot(req).await.expect("endpoint response")
|
||||
}
|
||||
|
||||
pub async fn bound_member_input(&self, username: &str) -> String {
|
||||
self.app_state.active_users.bound_member_input(username).await.map(|s| s.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn active_session_vids(&self, username: &str) -> Vec<u32> {
|
||||
self.app_state.active_users.active_session_vids(username).await
|
||||
}
|
||||
|
||||
pub fn slots_in_use(&self) -> usize { self.app_state.active_provider.get_provider_connections_count() }
|
||||
|
||||
pub async fn republish_without_groups(&self) {
|
||||
let mut playlist = vec![shared::model::PlaylistGroup {
|
||||
id: 1,
|
||||
title: "News".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002"),
|
||||
id: "plain".intern(),
|
||||
name: "Plain TV".intern(),
|
||||
title: "Plain TV".intern(),
|
||||
group: "News".intern(),
|
||||
url: "http://127.0.0.1/plain".intern(),
|
||||
input_name: self.inputs()[0].as_str().intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
}];
|
||||
persist_playlist_views_with_groups(
|
||||
&self.app_state.app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&self.target,
|
||||
Some(&self.app_state.playlists),
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
.expect("republished without groups");
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<String> { self.origins.keys().cloned().collect() }
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub(crate) struct GroupPlaybackEnvBuilder {
|
||||
inputs: Vec<String>,
|
||||
origin_modes: HashMap<String, FakeOriginMode>,
|
||||
qos: Vec<(String, u32, u8)>,
|
||||
hls: bool,
|
||||
stalker: Option<String>,
|
||||
user_limit: Option<u32>,
|
||||
admission: Option<Vec<AdmissionStrategy>>,
|
||||
favourite: bool,
|
||||
plain_channel: bool,
|
||||
member_archives: Vec<Option<i32>>,
|
||||
epg: Option<Epg>,
|
||||
epg_channel_id: Option<String>,
|
||||
hdhomerun: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl GroupPlaybackEnvBuilder {
|
||||
pub fn origin_status(mut self, input: &str, status: u16) -> Self {
|
||||
self.origin_modes.insert(input.to_string(), FakeOriginMode::Status(status));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn origin_body(mut self, input: &str, content_type: &str, body: Vec<u8>) -> Self {
|
||||
self.origin_modes
|
||||
.insert(input.to_string(), FakeOriginMode::Body { content_type: content_type.to_string(), bytes: body });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_qos(mut self, input: &str, provider_id: u32, score: u8) -> Self {
|
||||
self.qos.push((input.to_string(), provider_id, score));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_favourite(mut self) -> Self {
|
||||
self.favourite = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hls(mut self) -> Self {
|
||||
self.hls = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stalker(mut self, input: &str) -> Self {
|
||||
self.stalker = Some(input.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn user_limit(mut self, limit: u32) -> Self {
|
||||
self.user_limit = Some(limit);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn admission(mut self, strategies: Vec<AdmissionStrategy>) -> Self {
|
||||
self.admission = Some(strategies);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn without_plain_channel(mut self) -> Self {
|
||||
self.plain_channel = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_member_archives(mut self, archives: &[Option<i32>]) -> Self {
|
||||
self.member_archives = archives.to_vec();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_epg(mut self, epg: Epg, channel_id: &str) -> Self {
|
||||
self.epg = Some(epg);
|
||||
self.epg_channel_id = Some(channel_id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hdhomerun(mut self) -> Self {
|
||||
self.hdhomerun = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn build(self) -> GroupPlaybackEnv {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
|
||||
let mut origins = HashMap::new();
|
||||
for input in &self.inputs {
|
||||
let mode = self.origin_modes.get(input).cloned().unwrap_or_else(|| {
|
||||
if self.hls {
|
||||
FakeOriginMode::Hls
|
||||
} else {
|
||||
FakeOriginMode::FixedBytes(ts_fixture())
|
||||
}
|
||||
});
|
||||
let origin = spawn_tracked_origin(mode).await;
|
||||
origins.insert(input.clone(), origin);
|
||||
}
|
||||
|
||||
let mut config_inputs = Vec::new();
|
||||
for input_name in &self.inputs {
|
||||
let origin = &origins[input_name];
|
||||
let is_stalker = self.stalker.as_deref() == Some(input_name.as_str());
|
||||
let provider = ConfigProvider {
|
||||
name: Arc::from(input_name.as_str()),
|
||||
urls: vec![Arc::from(format!("http://{}", origin.addr).as_str())],
|
||||
provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(),
|
||||
dns: None,
|
||||
current_url_index: std::sync::atomic::AtomicUsize::new(0),
|
||||
dns_cache: Arc::default(),
|
||||
};
|
||||
let input = Arc::new(ConfigInput {
|
||||
id: u16::try_from(config_inputs.len()).unwrap_or(0) + 1,
|
||||
name: Arc::from(input_name.as_str()),
|
||||
input_type: if is_stalker { InputType::Stalker } else { InputType::Xtream },
|
||||
url: format!("http://{}", origin.addr),
|
||||
username: Some("viewer".to_string()),
|
||||
password: Some("secret".to_string()),
|
||||
priority: 1,
|
||||
max_connections: 10,
|
||||
enabled: true,
|
||||
provider_configs: Some(vec![Arc::new(provider)]),
|
||||
stalker: if is_stalker {
|
||||
Some(crate::model::StalkerInputConfig {
|
||||
device: None,
|
||||
auth_mode: shared::model::StalkerAuthMode::Auto,
|
||||
mag_preset: shared::model::StalkerMagPreset::GenericSafe,
|
||||
endpoint_preference: shared::model::StalkerEndpointPreference::ServerLoad,
|
||||
size_caps: None,
|
||||
catalog_max_pages: None,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..ConfigInput::default()
|
||||
});
|
||||
config_inputs.push(input);
|
||||
}
|
||||
|
||||
let mut user = ProxyUserCredentials::default();
|
||||
user.username = "viewer".to_string();
|
||||
user.password = "secret".to_string();
|
||||
user.status = Some(ProxyUserStatus::Active);
|
||||
user.output_clusters = ClusterFlags::all();
|
||||
user.max_connections = self.user_limit.unwrap_or(2);
|
||||
user.proxy = shared::model::ProxyType::Reverse(None);
|
||||
|
||||
let storage_dir = dir.path().to_string_lossy().into_owned();
|
||||
let config = Config {
|
||||
storage_dir: storage_dir.clone(),
|
||||
user_access_control: true,
|
||||
reverse_proxy: Some(crate::model::ReverseProxyConfig {
|
||||
resource_rewrite_disabled: false,
|
||||
rewrite_secret: [0; 16],
|
||||
resource_retry: crate::model::ResourceRetryConfig::default(),
|
||||
disabled_header: None,
|
||||
stream: Some(StreamConfig { admission_strategies: self.admission, ..StreamConfig::default() }),
|
||||
cache: None,
|
||||
rate_limit: None,
|
||||
geoip: None,
|
||||
stream_history: None,
|
||||
qos_aggregation: None,
|
||||
hls_cache: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let target_name = "test_target".to_string();
|
||||
let api_proxy = crate::model::ApiProxyConfig {
|
||||
server: vec![crate::model::ApiProxyServerInfo {
|
||||
name: "default".to_string(),
|
||||
protocol: "https".to_string(),
|
||||
host: "example.test".to_string(),
|
||||
port: None,
|
||||
timezone: "UTC".to_string(),
|
||||
message: String::new(),
|
||||
path: Some("iptv".to_string()),
|
||||
}],
|
||||
user: vec![crate::model::TargetUser { target: target_name.clone(), credentials: vec![Arc::new(user)] }],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut target_outputs = vec![
|
||||
TargetOutput::Xtream(XtreamTargetOutput::from(&XtreamTargetOutputDto::default())),
|
||||
TargetOutput::M3u(M3uTargetOutput::from(&M3uTargetOutputDto {
|
||||
filename: Some("playlist.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
})),
|
||||
];
|
||||
if self.hdhomerun {
|
||||
target_outputs.push(TargetOutput::HdHomeRun(HdHomeRunTargetOutput::from(&HdHomeRunTargetOutputDto {
|
||||
device: "test_hdhr".to_string(),
|
||||
username: "viewer".to_string(),
|
||||
use_output: Some(TargetType::M3u),
|
||||
})));
|
||||
}
|
||||
|
||||
let target = Arc::new(ConfigTarget {
|
||||
id: 1,
|
||||
enabled: true,
|
||||
name: "test_target".to_string(),
|
||||
options: Some(ConfigTargetOptions {
|
||||
channel_groups: Some(ChannelGroupsConfig {
|
||||
enabled: true,
|
||||
selection: ChannelGroupSelectionConfig::default(),
|
||||
..ChannelGroupsConfig::default()
|
||||
}),
|
||||
..ConfigTargetOptions::default()
|
||||
}),
|
||||
output: target_outputs,
|
||||
filter: Filter::default().into(),
|
||||
sort: None,
|
||||
curation: None,
|
||||
rename: None,
|
||||
mapping_ids: None,
|
||||
mapping: Arc::new(arc_swap::ArcSwapOption::default()),
|
||||
favourites: None,
|
||||
processing_order: ProcessingOrder::default(),
|
||||
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
|
||||
watch: None,
|
||||
use_memory_cache: false,
|
||||
});
|
||||
|
||||
let mut app_cfg = crate::api::api_utils::tests::support::create_test_app_config();
|
||||
app_cfg.config = Arc::new(ArcSwap::from_pointee(config));
|
||||
app_cfg.api_proxy = Arc::new(arc_swap::ArcSwapOption::from(Some(Arc::new(api_proxy))));
|
||||
app_cfg.sources = Arc::new(ArcSwap::from_pointee(SourcesConfig {
|
||||
inputs: config_inputs.clone(),
|
||||
sources: vec![crate::model::ConfigSource {
|
||||
inputs: config_inputs.iter().map(|i| Arc::clone(&i.name)).collect(),
|
||||
targets: vec![Arc::clone(&target)],
|
||||
}],
|
||||
..SourcesConfig::default()
|
||||
}));
|
||||
app_cfg.paths = Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: storage_dir.clone(),
|
||||
config_path: storage_dir.clone(),
|
||||
storage_path: storage_dir,
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
}));
|
||||
|
||||
let group_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
|
||||
let representative_input = &self.inputs[0];
|
||||
let representative_origin = &origins[representative_input];
|
||||
let rep_url = format!("http://{}/live/101", representative_origin.addr);
|
||||
|
||||
let group_epg = self.epg_channel_id.as_deref().map(Internable::intern);
|
||||
let max_archive = self.member_archives.iter().flatten().copied().max();
|
||||
let group_additional_props = max_archive.map(|dur| {
|
||||
StreamProperties::Live(Box::new(LiveStreamProperties {
|
||||
name: "ZDF HD".intern(),
|
||||
category_id: 1,
|
||||
stream_id: 0,
|
||||
stream_icon: "".intern(),
|
||||
direct_source: "".intern(),
|
||||
custom_sid: None,
|
||||
added: None,
|
||||
stream_type: None,
|
||||
epg_channel_id: group_epg.clone(),
|
||||
tv_archive: Some(1),
|
||||
tv_archive_duration: Some(dur),
|
||||
is_adult: 0,
|
||||
..Default::default()
|
||||
}))
|
||||
});
|
||||
|
||||
let group_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: group_uuid,
|
||||
id: "zdf-hd".intern(),
|
||||
input_stream_id: "101".intern(),
|
||||
name: "ZDF HD".intern(),
|
||||
title: "ZDF HD".intern(),
|
||||
group: "General".intern(),
|
||||
url: rep_url.as_str().intern(),
|
||||
input_name: Arc::from(representative_input.as_str()),
|
||||
item_type: if self.hls { PlaylistItemType::LiveHls } else { PlaylistItemType::Live },
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
channel_group_uuid: Some(group_uuid),
|
||||
epg_channel_id: group_epg.clone(),
|
||||
additional_properties: group_additional_props,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
|
||||
let plain_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002");
|
||||
let plain_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: plain_uuid,
|
||||
id: "plain-tv".intern(),
|
||||
input_stream_id: "plain".intern(),
|
||||
name: "Plain TV".intern(),
|
||||
title: "Plain TV".intern(),
|
||||
group: "General".intern(),
|
||||
url: format!("http://{}/live/plain", representative_origin.addr).as_str().intern(),
|
||||
input_name: Arc::from(representative_input.as_str()),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
|
||||
let mut members = Vec::new();
|
||||
for (idx, input_name) in self.inputs.iter().enumerate() {
|
||||
let origin = &origins[input_name];
|
||||
let member_url = format!("http://{}/live/101", origin.addr);
|
||||
let member_archive = self.member_archives.get(idx).copied().flatten();
|
||||
let member_additional_props = member_archive.map(|dur| {
|
||||
StreamProperties::Live(Box::new(LiveStreamProperties {
|
||||
name: format!("ZDF HD ({input_name})").intern(),
|
||||
category_id: 1,
|
||||
stream_id: 0,
|
||||
stream_icon: "".intern(),
|
||||
direct_source: "".intern(),
|
||||
custom_sid: None,
|
||||
added: None,
|
||||
stream_type: None,
|
||||
epg_channel_id: group_epg.clone(),
|
||||
tv_archive: Some(1),
|
||||
tv_archive_duration: Some(dur),
|
||||
is_adult: 0,
|
||||
..Default::default()
|
||||
}))
|
||||
});
|
||||
let member = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid(&format!("00000000-0000-4000-8000-0000000000{:02x}", idx + 10)),
|
||||
id: (idx + 1).to_string().intern(),
|
||||
input_stream_id: (idx + 1).to_string().intern(),
|
||||
name: format!("ZDF HD ({input_name})").intern(),
|
||||
title: "ZDF HD".intern(),
|
||||
group: "General".intern(),
|
||||
url: member_url.as_str().intern(),
|
||||
input_name: Arc::from(input_name.as_str()),
|
||||
item_type: if self.hls { PlaylistItemType::LiveHls } else { PlaylistItemType::Live },
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
epg_channel_id: group_epg.clone(),
|
||||
additional_properties: member_additional_props,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
members.push(member);
|
||||
}
|
||||
|
||||
let channels =
|
||||
if self.plain_channel { vec![group_channel.clone(), plain_channel] } else { vec![group_channel.clone()] };
|
||||
|
||||
let mut playlist = vec![shared::model::PlaylistGroup {
|
||||
id: 1,
|
||||
title: "General".intern(),
|
||||
channels,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
}];
|
||||
|
||||
let mut favourite_vid = 0;
|
||||
if self.favourite {
|
||||
let fav_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000099");
|
||||
let fav_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: fav_uuid,
|
||||
channel_group_uuid: Some(group_uuid),
|
||||
group: "Favourites".intern(),
|
||||
..group_channel.header.clone()
|
||||
},
|
||||
};
|
||||
playlist.push(shared::model::PlaylistGroup {
|
||||
id: 99,
|
||||
title: "Favourites".intern(),
|
||||
channels: vec![fav_channel],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
});
|
||||
}
|
||||
|
||||
let mut pending = vec![PendingChannelGroup {
|
||||
group_uuid,
|
||||
key: Arc::from("zdf hd"),
|
||||
origin: ChannelGroupOrigin::Auto,
|
||||
policy: ChannelGroupPolicy::Auto,
|
||||
members,
|
||||
orphaned: Vec::new(),
|
||||
epg_split: false,
|
||||
}];
|
||||
|
||||
let app_cfg = Arc::new(app_cfg);
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
persist_playlist_views_with_groups(
|
||||
&app_cfg,
|
||||
&mut playlist,
|
||||
None,
|
||||
self.epg.as_ref(),
|
||||
&target,
|
||||
Some(&playlists),
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.expect("persists playlist views");
|
||||
|
||||
let group_vid = playlist[0].channels[0].header.virtual_id.get();
|
||||
let plain_vid = if self.plain_channel { playlist[0].channels[1].header.virtual_id.get() } else { 0 };
|
||||
if self.favourite {
|
||||
favourite_vid = playlist[1].channels[0].header.virtual_id.get();
|
||||
}
|
||||
|
||||
let config_snapshot = app_cfg.config.load();
|
||||
let path = get_target_storage_path(&config_snapshot, &target.name).expect("path");
|
||||
let record =
|
||||
ChannelGroupStore::read_group(&path, VirtualId::new(group_vid)).expect("read").expect("record exists");
|
||||
let group_record = Arc::new(record);
|
||||
|
||||
let app_state = crate::api::api_utils::tests::support::create_test_app_state_for_config(Arc::clone(&app_cfg));
|
||||
if let Some(cfg) = target.options.as_ref().and_then(|o| o.channel_groups.as_ref()).map(|cg| &cg.selection) {
|
||||
app_state.member_health.set_policy(tuliprox_session::channel_group::CircuitPolicy::from(cfg));
|
||||
}
|
||||
app_state
|
||||
.member_health
|
||||
.set_event_sink(Arc::clone(&app_state.event_manager) as Arc<dyn shared::model::EventSink>);
|
||||
|
||||
for (input, provider_id, score) in self.qos {
|
||||
app_state.member_health.replace_qos([(
|
||||
tuliprox_session::channel_group::QosKey::Provider { input: Arc::from(input.as_str()), provider_id },
|
||||
tuliprox_session::channel_group::QosPart {
|
||||
score_24h: score,
|
||||
score_7d: score,
|
||||
confidence: 100,
|
||||
failure_streak: 0,
|
||||
},
|
||||
)]);
|
||||
}
|
||||
|
||||
let router = xtream_api_register()
|
||||
.merge(m3u_api_register())
|
||||
.merge(crate::api::endpoints::hls_api::hls_api_register())
|
||||
.merge(xmltv_api_register())
|
||||
.with_state(Arc::clone(&app_state));
|
||||
|
||||
let hdhr_router = if self.hdhomerun {
|
||||
let hdhr_state = Arc::new(HdHomerunAppState {
|
||||
app_state: Arc::clone(&app_state),
|
||||
device: Arc::new(HdHomeRunDeviceConfig {
|
||||
friendly_name: "Tuliprox HDHR".to_string(),
|
||||
manufacturer: "Tuliprox".to_string(),
|
||||
model_name: "HDHR-TEST".to_string(),
|
||||
model_number: "HDHR4-2US".to_string(),
|
||||
firmware_name: "hdhomerun4_atsc".to_string(),
|
||||
firmware_version: "20260101".to_string(),
|
||||
device_id: "12345678".to_string(),
|
||||
device_type: "tuner".to_string(),
|
||||
device_udn: "uuid:12345678-0000-0000-0000-000000000000".to_string(),
|
||||
name: "test_hdhr".to_string(),
|
||||
port: 5004,
|
||||
tuner_count: 2,
|
||||
t_username: "viewer".to_string(),
|
||||
t_enabled: true,
|
||||
}),
|
||||
hd_scan_state: Arc::new(AtomicI8::new(-1)),
|
||||
});
|
||||
Some(hdhr_api_register(false).with_state(hdhr_state))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
GroupPlaybackEnv {
|
||||
app_state,
|
||||
target,
|
||||
group_vid,
|
||||
plain_vid,
|
||||
favourite_vid,
|
||||
group_record,
|
||||
origins,
|
||||
router,
|
||||
hdhr_router,
|
||||
_dir: dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
create_test_app_state_for_config, create_test_fingerprint, create_test_live_channel,
|
||||
create_test_provider_app_config, create_test_provider_app_state, stream_response,
|
||||
create_test_provider_app_config, create_test_provider_app_state, stream_response, PlaybackSource,
|
||||
};
|
||||
use crate::{
|
||||
api::{api_utils::blocks_new_playback, model::AppState},
|
||||
@@ -172,10 +172,9 @@ async fn new_ts_stream_for_input_in_maintenance_gets_the_clip_without_provider_s
|
||||
"maintenance-session",
|
||||
None,
|
||||
create_test_live_channel(stream_url),
|
||||
stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&maintenance_clip_target(),
|
||||
&viewer(),
|
||||
shared::model::UserConnectionPermission::Allowed,
|
||||
|
||||
@@ -872,6 +872,7 @@ async fn xtream_vod_m3u_reverse_proxies_external_sources_and_alias_redirects_wit
|
||||
Some("mp4"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::{
|
||||
is_media_server_playback_url, is_media_server_stream_ref_url, is_seek_request, is_seekable_media_request,
|
||||
mark_response_as_uncompressed, resolve_request_url_for_logging, resolve_stream_config_u64, resource_proxy_response,
|
||||
spawn_legacy_hls_test_origin, stream_response, stream_responses::forced_legacy_hls_test_response,
|
||||
user_with_network_access, NetworkAccessDecision,
|
||||
user_with_network_access, NetworkAccessDecision, PlaybackSource,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{SharedStreamCtx, SharedStreamManager},
|
||||
@@ -365,10 +365,9 @@ async fn stream_response_preserves_soft_kind_for_shared_reuse() {
|
||||
"soft-session",
|
||||
None,
|
||||
create_test_live_channel(stream_url),
|
||||
stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
admission.permission(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::{
|
||||
activate_session_before_stream_open, create_test_app_config, create_test_app_state,
|
||||
create_test_app_state_for_config, create_test_app_state_with_stream_config, create_test_fingerprint,
|
||||
create_test_live_channel, create_test_provider_app_config, resolve_admission_with_strategies, stream_response,
|
||||
AdmissionRequest, EvictionReentryGuard, PlaybackRequestClass, SessionActivationRequest,
|
||||
AdmissionRequest, EvictionReentryGuard, PlaybackRequestClass, PlaybackSource, SessionActivationRequest,
|
||||
};
|
||||
use crate::model::{Config, ConfigTarget, ProxyUserCredentials};
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
@@ -573,10 +573,9 @@ async fn stream_response_rolls_back_provisional_user_activation_when_provider_op
|
||||
"rollback-session",
|
||||
None,
|
||||
channel,
|
||||
stream_url,
|
||||
PlaybackSource::Single { input: &input, stream_url },
|
||||
None,
|
||||
&HeaderMap::default(),
|
||||
&input,
|
||||
&target,
|
||||
&user,
|
||||
UserConnectionPermission::Allowed,
|
||||
|
||||
@@ -110,6 +110,9 @@ pub(in crate::api::api_utils::tests) enum FakeOriginMode {
|
||||
ErrorBeforeFirstChunk,
|
||||
ErrorAfterFirstChunk(Vec<u8>),
|
||||
FixedBytes(Vec<u8>),
|
||||
Status(u16),
|
||||
Body { content_type: String, bytes: Vec<u8> },
|
||||
Hls,
|
||||
}
|
||||
|
||||
pub(in crate::api::api_utils::tests) async fn spawn_controlled_fake_origin(
|
||||
@@ -178,6 +181,25 @@ pub(in crate::api::api_utils::tests) async fn spawn_controlled_fake_origin(
|
||||
let _ = socket.write_all(bytes).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Status(status) => {
|
||||
let head = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Body { content_type, bytes } => {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
);
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.write_all(bytes).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
FakeOriginMode::Hls => {
|
||||
let head = "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
requests
|
||||
@@ -470,6 +492,7 @@ pub(in crate::api::api_utils::tests) fn create_test_app_state_for_config(app_cfg
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -507,6 +530,7 @@ pub(in crate::api::api_utils::tests) fn create_test_local_channel(url: &str) ->
|
||||
StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 41,
|
||||
group_virtual_id: None,
|
||||
provider_id: 0,
|
||||
input_name: "library".intern(),
|
||||
item_type: PlaylistItemType::LocalVideo,
|
||||
@@ -528,6 +552,7 @@ pub(in crate::api::api_utils::tests) fn create_test_live_channel(url: &str) -> S
|
||||
StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 42,
|
||||
group_virtual_id: None,
|
||||
provider_id: 1,
|
||||
input_name: "provider_1".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
|
||||
@@ -738,6 +738,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,49 @@ 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 (selection_cfg, allow_provider_grace) =
|
||||
target.options.as_ref().and_then(|o| o.channel_groups.as_ref()).map_or_else(
|
||||
|| (shared::model::ChannelGroupSelectionConfig::default(), true),
|
||||
|cg| (cg.selection, cg.allow_provider_grace),
|
||||
);
|
||||
let policy = group.policy;
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
|
||||
let req = crate::api::api_utils::GroupSelectionRequest {
|
||||
target_name: &target_name,
|
||||
group: &group,
|
||||
policy,
|
||||
mode: tuliprox_session::channel_group::SelectionMode::Live,
|
||||
selection: selection_cfg,
|
||||
addr: &fingerprint.addr,
|
||||
username: &user.username,
|
||||
priority: connection_priority_for_kind(user, connection_kind),
|
||||
kind: connection_kind,
|
||||
lease: Some(PlaybackLeaseRef::new(session_owner, PlaybackKind::LiveHls)),
|
||||
allow_provider_grace,
|
||||
exclude: &[],
|
||||
demoted_inputs: &[],
|
||||
prefer: None,
|
||||
};
|
||||
let Ok(resolved) =
|
||||
crate::api::api_utils::resolve_hls_group_entry(app_state, target, 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, 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, SelectionMode},
|
||||
MemberBinding,
|
||||
};
|
||||
|
||||
pub(in crate::api::endpoints::hls_api) struct HlsCacheManifestOrigin<'a> {
|
||||
pub(in crate::api::endpoints::hls_api) raw_request_url: &'a str,
|
||||
@@ -120,7 +125,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(|| {
|
||||
@@ -175,8 +180,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 +203,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 +218,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 +234,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 +268,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 +295,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 +309,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 +330,207 @@ 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 (selection_cfg, allow_provider_grace) =
|
||||
target.options.as_ref().and_then(|o| o.channel_groups.as_ref()).map_or_else(
|
||||
|| (shared::model::ChannelGroupSelectionConfig::default(), true),
|
||||
|cg| (cg.selection, cg.allow_provider_grace),
|
||||
);
|
||||
let policy = group.policy;
|
||||
let target_name: Arc<str> = Arc::from(target.name.as_str());
|
||||
|
||||
let req = GroupSelectionRequest {
|
||||
target_name: &target_name,
|
||||
group: &group,
|
||||
policy,
|
||||
mode: SelectionMode::Live,
|
||||
selection: selection_cfg,
|
||||
addr: &fingerprint.addr,
|
||||
username: &user.username,
|
||||
priority: connection_priority_for_kind(user, fallback_connection_kind),
|
||||
kind: fallback_connection_kind,
|
||||
lease: Some(PlaybackLeaseRef::new(user_session_token.as_str(), playback_kind)),
|
||||
allow_provider_grace,
|
||||
exclude: &[],
|
||||
demoted_inputs: &[],
|
||||
prefer: None,
|
||||
};
|
||||
|
||||
let Ok(resolved) = resolve_hls_group_entry(app_state, target, 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 +561,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 +591,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 +612,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 +623,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 +657,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 +675,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 +719,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,38 @@
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -527,16 +555,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 +775,7 @@ async fn m3u_api_stream(
|
||||
Some(archive.extension()),
|
||||
Some(&discriminator),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
@@ -723,10 +783,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 +813,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 +832,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 +858,7 @@ async fn m3u_api_stream(
|
||||
stream_ext,
|
||||
archive_discriminator.as_deref(),
|
||||
None,
|
||||
source,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
@@ -843,6 +935,7 @@ async fn m3u_api_stream_nested(
|
||||
stream_ext,
|
||||
archive_discriminator.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
@@ -911,6 +1004,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}");
|
||||
}
|
||||
@@ -172,5 +172,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,10 @@ 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 { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -748,7 +751,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; 31] = [
|
||||
EventKind::ConnectionDenied,
|
||||
EventKind::ScheduledTaskFailed,
|
||||
EventKind::ProviderFetchFailed,
|
||||
@@ -777,6 +780,9 @@ mod tests {
|
||||
EventKind::AuthSignInFailed,
|
||||
EventKind::AuthSignInThrottled,
|
||||
EventKind::AuthPermissionDenied,
|
||||
EventKind::ChannelGroupMemberSelected,
|
||||
EventKind::ChannelGroupExhausted,
|
||||
EventKind::ChannelGroupCircuitChanged,
|
||||
];
|
||||
|
||||
for (event, kind) in sample_event_of_every_kind() {
|
||||
@@ -941,6 +947,18 @@ 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::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,
|
||||
@@ -715,16 +749,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 +1135,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,
|
||||
|
||||
@@ -19,12 +19,12 @@ use crate::{
|
||||
model::{
|
||||
create_cache, create_http_client, create_http_client_no_redirect, create_public_http_client_no_redirect,
|
||||
create_resource_http_client_no_redirect, create_resource_public_http_client_no_redirect, exec_provider_dns,
|
||||
exec_qos_aggregation, load_playlists_into_memory_cache,
|
||||
exec_qos_aggregation_with_listener, load_playlists_into_memory_cache,
|
||||
recording_rule_scheduler::spawn_recording_rule_scheduler,
|
||||
recording_supervisor::start_recording_supervisors, ActiveProviderManager, ActiveUserManager, AppState,
|
||||
AuthState, CancelTokens, ConnectionManager, EventManager, EventMessage, HdHomerunAppState, HlsState,
|
||||
ManualPlaylistUpdateRequest, MetadataUpdateManager, PlaylistStorageState, PlaylistUpdateControl,
|
||||
RecordingQueue, SharedStreamManager,
|
||||
recording_supervisor::start_recording_supervisors, ActiveProviderManager, ActiveUserManager,
|
||||
AppQosAggregationListener, AppState, AuthState, CancelTokens, ConnectionManager, EventManager,
|
||||
EventMessage, HdHomerunAppState, HlsState, ManualPlaylistUpdateRequest, MetadataUpdateManager,
|
||||
PlaylistStorageState, PlaylistUpdateControl, RecordingQueue, SharedStreamManager,
|
||||
},
|
||||
panel_api::{sync_panel_api_exp_dates, sync_panel_api_exp_dates_on_boot},
|
||||
serve::serve,
|
||||
@@ -291,6 +291,49 @@ pub(crate) async fn load_maintenance(app_config: &AppConfig) -> Arc<MaintenanceR
|
||||
Arc::new(MaintenanceRegistry::load(path, &known).await)
|
||||
}
|
||||
|
||||
/// Create all HTTP clients required by the server state.
|
||||
fn create_all_http_clients(app_config: &Arc<AppConfig>) -> Result<Arc<HttpClients>, TuliproxError> {
|
||||
Ok(Arc::new(HttpClients::new(
|
||||
create_http_client(app_config)?,
|
||||
create_http_client_no_redirect(app_config)?,
|
||||
create_public_http_client_no_redirect(app_config)?,
|
||||
create_resource_http_client_no_redirect(app_config)?,
|
||||
create_resource_public_http_client_no_redirect(app_config)?,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Populate initial `QoS` snapshots from repository into the health index.
|
||||
fn populate_initial_member_health(storage_dir: &std::path::Path, member_health: &tuliprox_session::MemberHealthIndex) {
|
||||
let mut initial_snapshots = Vec::new();
|
||||
let _ = tuliprox_repository::QosSnapshotRepository::for_each_snapshot_read_only(storage_dir, |record| {
|
||||
initial_snapshots.push(record.clone());
|
||||
});
|
||||
if initial_snapshots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let entries = initial_snapshots.iter().map(|record| {
|
||||
let key = if record.provider_id > 0 {
|
||||
tuliprox_session::QosKey::Provider {
|
||||
input: Arc::clone(&record.input_name),
|
||||
provider_id: record.provider_id,
|
||||
}
|
||||
} else {
|
||||
tuliprox_session::QosKey::TargetMember {
|
||||
target: Arc::clone(&record.target_name),
|
||||
member_vid: record.virtual_id,
|
||||
}
|
||||
};
|
||||
let part = tuliprox_session::QosPart {
|
||||
score_24h: record.window_24h.score,
|
||||
score_7d: record.window_7d.score,
|
||||
confidence: record.window_24h.confidence,
|
||||
failure_streak: record.window_24h.successive_failure_streak,
|
||||
};
|
||||
(key, part)
|
||||
});
|
||||
member_health.replace_qos(entries);
|
||||
}
|
||||
|
||||
async fn create_shared_data(
|
||||
app_config: &Arc<AppConfig>,
|
||||
forced_targets: &Arc<ProcessTargets>,
|
||||
@@ -352,11 +395,7 @@ async fn create_shared_data(
|
||||
cleanup_capacity,
|
||||
));
|
||||
|
||||
let client = create_http_client(app_config)?;
|
||||
let client_no_redirect = create_http_client_no_redirect(app_config)?;
|
||||
let public_client_no_redirect = create_public_http_client_no_redirect(app_config)?;
|
||||
let resource_client_no_redirect = create_resource_http_client_no_redirect(app_config)?;
|
||||
let resource_public_client_no_redirect = create_resource_public_http_client_no_redirect(app_config)?;
|
||||
let http_clients = create_all_http_clients(app_config)?;
|
||||
|
||||
let tokens = CancelTokens::default();
|
||||
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
|
||||
@@ -372,16 +411,14 @@ async fn create_shared_data(
|
||||
})?;
|
||||
|
||||
let maintenance = load_maintenance(app_config).await;
|
||||
let member_health = Arc::new(tuliprox_session::MemberHealthIndex::default());
|
||||
event_manager.bind_member_health(&member_health);
|
||||
member_health.set_event_sink(Arc::clone(&event_manager) as Arc<dyn shared::model::EventSink>);
|
||||
populate_initial_member_health(std::path::Path::new(&config.storage_dir), &member_health);
|
||||
|
||||
let app_state = AppState {
|
||||
app_config: Arc::clone(app_config),
|
||||
http_clients: Arc::new(HttpClients::new(
|
||||
client,
|
||||
client_no_redirect,
|
||||
public_client_no_redirect,
|
||||
resource_client_no_redirect,
|
||||
resource_public_client_no_redirect,
|
||||
)),
|
||||
http_clients,
|
||||
recordings: Arc::new(recordings),
|
||||
cache: Arc::new(ArcSwapOption::from(cache)),
|
||||
shared_stream_manager,
|
||||
@@ -403,6 +440,7 @@ async fn create_shared_data(
|
||||
auth: AuthState::new(token_revocations),
|
||||
playlist_updates: PlaylistUpdateControl::new(Arc::clone(forced_targets), manual_update_sender),
|
||||
maintenance,
|
||||
member_health,
|
||||
};
|
||||
|
||||
Ok((app_state, manual_update_rx))
|
||||
@@ -709,7 +747,11 @@ pub async fn start_server(app_config: Arc<AppConfig>, targets: Arc<ProcessTarget
|
||||
exec_interner_prune(&app_state);
|
||||
exec_config_watch(&app_state, &cancel_token_file_watch);
|
||||
exec_provider_dns(&app_state.app_config, &cancel_token_provider_dns);
|
||||
exec_qos_aggregation(&app_state.app_config, &cancel_token_qos_aggregation);
|
||||
exec_qos_aggregation_with_listener(
|
||||
&app_state.app_config,
|
||||
&cancel_token_qos_aggregation,
|
||||
Some(Arc::new(AppQosAggregationListener { member_health: Arc::clone(&app_state.member_health) })),
|
||||
);
|
||||
exec_hls_lifecycle(&app_state.hls_ctx(), &app_state.cancel_tokens.load().hls_cache);
|
||||
exec_hls_cache_gc(&app_state.hls_ctx(), &app_state.cancel_tokens.load().hls_cache);
|
||||
|
||||
@@ -1178,6 +1220,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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AppState {
|
||||
pub playlist_updates: PlaylistUpdateControl,
|
||||
/// Inputs taken out of service; read on every new stream request.
|
||||
pub maintenance: Arc<MaintenanceRegistry>,
|
||||
pub member_health: Arc<tuliprox_session::MemberHealthIndex>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -121,3 +122,33 @@ pub struct HdHomerunAppState {
|
||||
pub device: Arc<HdHomeRunDeviceConfig>,
|
||||
pub hd_scan_state: Arc<AtomicI8>,
|
||||
}
|
||||
|
||||
pub struct AppQosAggregationListener {
|
||||
pub member_health: Arc<tuliprox_session::MemberHealthIndex>,
|
||||
}
|
||||
|
||||
impl tuliprox_session::qos_aggregation_manager::QosAggregationListener for AppQosAggregationListener {
|
||||
fn on_snapshots(&self, snapshots: &[tuliprox_repository::QosSnapshotRecord]) {
|
||||
let entries = snapshots.iter().map(|record| {
|
||||
let key = if record.provider_id > 0 {
|
||||
tuliprox_session::QosKey::Provider {
|
||||
input: Arc::clone(&record.input_name),
|
||||
provider_id: record.provider_id,
|
||||
}
|
||||
} else {
|
||||
tuliprox_session::QosKey::TargetMember {
|
||||
target: Arc::clone(&record.target_name),
|
||||
member_vid: record.virtual_id,
|
||||
}
|
||||
};
|
||||
let part = tuliprox_session::QosPart {
|
||||
score_24h: record.window_24h.score,
|
||||
score_7d: record.window_7d.score,
|
||||
confidence: record.window_24h.confidence,
|
||||
failure_streak: record.window_24h.successive_failure_streak,
|
||||
};
|
||||
(key, part)
|
||||
});
|
||||
self.member_health.replace_qos(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ use shared::{error::TuliproxError, model::WebAuthConfigDto};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tuliprox_dvr::recording::recording_transfer::{resume_recording_worker_if_needed, spawn_recording_services};
|
||||
use tuliprox_session::{provider_dns_manager::exec_provider_dns, qos_aggregation_manager::exec_qos_aggregation};
|
||||
use tuliprox_session::{
|
||||
provider_dns_manager::exec_provider_dns, qos_aggregation_manager::exec_qos_aggregation_with_listener,
|
||||
};
|
||||
|
||||
macro_rules! cancel_service {
|
||||
($field: ident, $flag:expr, $changes:expr, $cancel_tokens:expr) => {
|
||||
@@ -166,7 +168,11 @@ fn start_services(app_state: &Arc<AppState>, changes: &UpdateChanges) {
|
||||
exec_provider_dns(&app_state.app_config, &app_state.cancel_tokens.load().provider_dns);
|
||||
}
|
||||
if changes.flags.contains(UpdateChangesFlags::QosAggregation) {
|
||||
exec_qos_aggregation(&app_state.app_config, &app_state.cancel_tokens.load().qos_aggregation);
|
||||
exec_qos_aggregation_with_listener(
|
||||
&app_state.app_config,
|
||||
&app_state.cancel_tokens.load().qos_aggregation,
|
||||
Some(Arc::new(super::AppQosAggregationListener { member_health: Arc::clone(&app_state.member_health) })),
|
||||
);
|
||||
let history_cfg =
|
||||
app_state.app_config.config.load().reverse_proxy.as_ref().and_then(|rp| rp.stream_history.clone());
|
||||
let connection_manager = Arc::clone(&app_state.connection_manager);
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn create_test_app_state(config: Config) -> Arc<AppState> {
|
||||
auth: AuthState::for_tests(),
|
||||
playlist_updates: PlaylistUpdateControl::for_tests(),
|
||||
maintenance,
|
||||
member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +148,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_s
|
||||
StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id,
|
||||
group_virtual_id: None,
|
||||
provider_id: 1,
|
||||
input_name: "input".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
|
||||
@@ -44,7 +44,8 @@ create_bitset!(
|
||||
RangeRequested,
|
||||
PublicDestinationRequired,
|
||||
HlsResource,
|
||||
FlussonicAudioTracks
|
||||
FlussonicAudioTracks,
|
||||
GroupMember
|
||||
);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -124,7 +124,9 @@ pub async fn create_provider_stream(
|
||||
Some(status),
|
||||
Some(failure.provider_error_class()),
|
||||
);
|
||||
if stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource) {
|
||||
if stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource)
|
||||
|| stream_options.flags.contains(ProviderStreamFactoryFlags::GroupMember)
|
||||
{
|
||||
let headers = match failure {
|
||||
ProviderStreamRequestFailure::HlsStatus { headers, .. } => headers,
|
||||
ProviderStreamRequestFailure::Status { .. } => Vec::new(),
|
||||
|
||||
@@ -42,6 +42,41 @@ pub(crate) struct ProviderStreamFactoryParams<'a> {
|
||||
pub content_representation: ProviderContentRepresentationMode,
|
||||
}
|
||||
|
||||
fn compute_flags(
|
||||
stream_options: &StreamOptions,
|
||||
item_type: PlaylistItemType,
|
||||
share_stream: bool,
|
||||
range_requested: bool,
|
||||
is_group_member: bool,
|
||||
) -> ProviderStreamFactoryFlagsSet {
|
||||
let mut flags = ProviderStreamFactoryFlagsSet::new();
|
||||
if stream_options.stream_retry {
|
||||
flags.set(ProviderStreamFactoryFlags::RetryEnabled);
|
||||
if !item_type.is_live_adaptive() && stream_options.response_mode != StreamResponseMode::HlsResource {
|
||||
flags.set(ProviderStreamFactoryFlags::InitialRetryLoopEnabled);
|
||||
}
|
||||
}
|
||||
if stream_options.pipe_provider_stream {
|
||||
flags.set(ProviderStreamFactoryFlags::PipeStream);
|
||||
}
|
||||
if stream_options.buffer_enabled {
|
||||
flags.set(ProviderStreamFactoryFlags::BufferEnabled);
|
||||
}
|
||||
if stream_options.response_mode == StreamResponseMode::HlsResource {
|
||||
flags.set(ProviderStreamFactoryFlags::HlsResource);
|
||||
}
|
||||
if share_stream {
|
||||
flags.set(ProviderStreamFactoryFlags::ShareStream);
|
||||
}
|
||||
if range_requested {
|
||||
flags.set(ProviderStreamFactoryFlags::RangeRequested);
|
||||
}
|
||||
if is_group_member {
|
||||
flags.set(ProviderStreamFactoryFlags::GroupMember);
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
impl ProviderStreamFactoryOptions {
|
||||
pub(crate) fn new(request: &ProviderStreamFactoryParams<'_>) -> Self {
|
||||
let ProviderStreamFactoryParams {
|
||||
@@ -97,28 +132,13 @@ impl ProviderStreamFactoryOptions {
|
||||
})
|
||||
.and_then(|ua| axum::http::header::HeaderValue::from_str(ua).ok());
|
||||
let url = (*stream_url).clone();
|
||||
let mut flags = ProviderStreamFactoryFlagsSet::new();
|
||||
if stream_options.stream_retry {
|
||||
flags.set(ProviderStreamFactoryFlags::RetryEnabled);
|
||||
if !item_type.is_live_adaptive() && stream_options.response_mode != StreamResponseMode::HlsResource {
|
||||
flags.set(ProviderStreamFactoryFlags::InitialRetryLoopEnabled);
|
||||
}
|
||||
}
|
||||
if stream_options.pipe_provider_stream {
|
||||
flags.set(ProviderStreamFactoryFlags::PipeStream);
|
||||
}
|
||||
if stream_options.buffer_enabled {
|
||||
flags.set(ProviderStreamFactoryFlags::BufferEnabled);
|
||||
}
|
||||
if stream_options.response_mode == StreamResponseMode::HlsResource {
|
||||
flags.set(ProviderStreamFactoryFlags::HlsResource);
|
||||
}
|
||||
if *share_stream {
|
||||
flags.set(ProviderStreamFactoryFlags::ShareStream);
|
||||
}
|
||||
if requested_range.is_some() {
|
||||
flags.set(ProviderStreamFactoryFlags::RangeRequested);
|
||||
}
|
||||
let flags = compute_flags(
|
||||
stream_options,
|
||||
*item_type,
|
||||
*share_stream,
|
||||
requested_range.is_some(),
|
||||
stream_channel.as_ref().and_then(|ch| ch.group_virtual_id).is_some(),
|
||||
);
|
||||
|
||||
Self {
|
||||
item_type: *item_type,
|
||||
|
||||
@@ -183,8 +183,10 @@ pub(super) async fn send_with_manual_redirects(
|
||||
provider.as_ref(),
|
||||
true,
|
||||
stream_options.should_retry_provider_request(),
|
||||
RequestFetchOptions::default()
|
||||
.with_http_error_responses(stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource)),
|
||||
RequestFetchOptions::default().with_http_error_responses(
|
||||
stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource)
|
||||
|| stream_options.flags.contains(ProviderStreamFactoryFlags::GroupMember),
|
||||
),
|
||||
|resolved_url| prepare_client(request_client, stream_options, Some(resolved_url), credential_state).0,
|
||||
)
|
||||
.await;
|
||||
@@ -263,8 +265,10 @@ async fn provider_stream_request(
|
||||
provider.as_ref(),
|
||||
false,
|
||||
stream_options.should_retry_provider_request(),
|
||||
RequestFetchOptions::default()
|
||||
.with_http_error_responses(stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource)),
|
||||
RequestFetchOptions::default().with_http_error_responses(
|
||||
stream_options.flags.contains(ProviderStreamFactoryFlags::HlsResource)
|
||||
|| stream_options.flags.contains(ProviderStreamFactoryFlags::GroupMember),
|
||||
),
|
||||
|resolved_url| {
|
||||
let (client, _partial_content) = prepare_client(
|
||||
request_client,
|
||||
@@ -436,6 +440,10 @@ pub(super) async fn get_provider_stream(
|
||||
debug_if_enabled!("stream provider {}", sanitize_sensitive_info(log_url.as_ref()));
|
||||
let start = Instant::now();
|
||||
let mut connect_err: u32 = 1;
|
||||
let is_group_member = stream_options.flags.contains(ProviderStreamFactoryFlags::GroupMember);
|
||||
let max_retry_count = if is_group_member { 1 } else { ERR_MAX_RETRY_COUNT };
|
||||
let retry_seconds = if is_group_member { RETRY_SECONDS.min(3) } else { RETRY_SECONDS };
|
||||
let mut last_failure: Option<ProviderStreamRequestFailure> = None;
|
||||
|
||||
let handle_cancel = stream_options.get_cancel_token();
|
||||
while stream_options.should_continue() {
|
||||
@@ -464,7 +472,7 @@ pub(super) async fn get_provider_stream(
|
||||
return Ok(Some(stream_response));
|
||||
}
|
||||
Ok(None) => {
|
||||
if connect_err > ERR_MAX_RETRY_COUNT {
|
||||
if connect_err >= max_retry_count {
|
||||
warn!(
|
||||
"The stream could be unavailable. {}",
|
||||
sanitize_sensitive_info(stream_options.get_log_url().as_ref())
|
||||
@@ -495,26 +503,29 @@ pub(super) async fn get_provider_stream(
|
||||
"The stream could be unavailable. ({status}) {}",
|
||||
sanitize_sensitive_info(stream_options.get_log_url().as_ref())
|
||||
);
|
||||
last_failure = Some(failure);
|
||||
break;
|
||||
}
|
||||
if connect_err > ERR_MAX_RETRY_COUNT {
|
||||
if connect_err >= max_retry_count {
|
||||
warn!(
|
||||
"The stream could be unavailable. ({status}) {}",
|
||||
sanitize_sensitive_info(stream_options.get_log_url().as_ref())
|
||||
);
|
||||
last_failure = Some(failure);
|
||||
break;
|
||||
}
|
||||
last_failure = Some(failure);
|
||||
}
|
||||
}
|
||||
if !stream_options.should_continue() || connect_err > ERR_MAX_RETRY_COUNT {
|
||||
if !stream_options.should_continue() || connect_err >= max_retry_count {
|
||||
break;
|
||||
}
|
||||
if !stream_options.should_retry_initial_open_loop() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed().as_secs() > RETRY_SECONDS {
|
||||
if start.elapsed().as_secs() > retry_seconds {
|
||||
warn!(
|
||||
"The stream could be unavailable. Giving up after {RETRY_SECONDS} seconds. {}",
|
||||
"The stream could be unavailable. Giving up after {retry_seconds} seconds. {}",
|
||||
sanitize_sensitive_info(stream_options.get_log_url().as_ref())
|
||||
);
|
||||
break;
|
||||
@@ -533,6 +544,9 @@ pub(super) async fn get_provider_stream(
|
||||
}
|
||||
debug_if_enabled!("Stopped reconnecting stream {}", sanitize_sensitive_info(stream_options.get_log_url().as_ref()));
|
||||
stream_options.cancel_reconnect();
|
||||
if let Some(failure) = last_failure {
|
||||
return Err(failure);
|
||||
}
|
||||
Err(ProviderStreamRequestFailure::Status {
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
provider_error_class: "service_unavailable",
|
||||
|
||||
@@ -102,6 +102,7 @@ fn test_provider_stream_factory_options_builds_connect_failed_stream_info_from_h
|
||||
stream_channel: Some(&StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 77,
|
||||
group_virtual_id: None,
|
||||
provider_id: 3,
|
||||
input_name: "input-a".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
|
||||
@@ -293,6 +293,7 @@ mod tests {
|
||||
let stream_channel = StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 1,
|
||||
group_virtual_id: None,
|
||||
provider_id: 1,
|
||||
input_name: "input".intern(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
|
||||
@@ -287,7 +287,10 @@ pub fn to_notification(message: &EventMessage) -> Option<NotificationEvent> {
|
||||
// which is the single place that decision is made.
|
||||
EventMessage::PlaylistUpdateProgress(_)
|
||||
| EventMessage::SystemInfoUpdate(_)
|
||||
| EventMessage::RecordingProgress => None,
|
||||
| EventMessage::RecordingProgress
|
||||
| EventMessage::ChannelGroupMemberSelected { .. }
|
||||
| EventMessage::ChannelGroupExhausted { .. }
|
||||
| EventMessage::ChannelGroupCircuitChanged { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,6 +634,18 @@ mod tests {
|
||||
auth_audit(AuthAuditOutcome::SignInFailed),
|
||||
auth_audit(AuthAuditOutcome::SignInThrottled),
|
||||
auth_audit(AuthAuditOutcome::PermissionDenied),
|
||||
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::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
|
||||
|
||||
@@ -9,9 +9,9 @@ use shared::{
|
||||
apply_flags, create_bitset,
|
||||
foundation::{Filter, ValueProvider},
|
||||
model::{
|
||||
ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, HdHomeRunTargetOutputDto, M3uTargetOutputDto,
|
||||
MappingStage, PlaylistItemType, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto,
|
||||
TargetType, TraktConfigDto, WatchContext, WatchContextSet, XtreamTargetOutputDto,
|
||||
ChannelGroupsConfig, ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, HdHomeRunTargetOutputDto,
|
||||
M3uTargetOutputDto, MappingStage, PlaylistItemType, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto,
|
||||
TargetOutputDto, TargetType, TraktConfigDto, WatchContext, WatchContextSet, XtreamTargetOutputDto,
|
||||
},
|
||||
};
|
||||
use std::{borrow::Cow, sync::Arc};
|
||||
@@ -31,6 +31,7 @@ pub struct TargetExecutionPlan {
|
||||
pub transform_stages: Vec<TransformStage>,
|
||||
pub pre_transform_identity_dedup: bool,
|
||||
pub post_merge_content_dedup: Option<DeduplicateConfig>,
|
||||
pub channel_groups: Option<ChannelGroupsConfig>,
|
||||
}
|
||||
|
||||
impl Default for TargetExecutionPlan {
|
||||
@@ -39,6 +40,7 @@ impl Default for TargetExecutionPlan {
|
||||
transform_stages: vec![TransformStage::Filter, TransformStage::Rename, TransformStage::Map],
|
||||
pre_transform_identity_dedup: false,
|
||||
post_merge_content_dedup: None,
|
||||
channel_groups: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +59,11 @@ impl TargetExecutionPlan {
|
||||
transform_stages,
|
||||
pre_transform_identity_dedup: dto.options.as_ref().is_some_and(|options| options.remove_duplicates),
|
||||
post_merge_content_dedup: dto.options.as_ref().and_then(|options| options.deduplicate),
|
||||
channel_groups: dto
|
||||
.options
|
||||
.as_ref()
|
||||
.and_then(|options| options.channel_groups.clone())
|
||||
.filter(|cfg| cfg.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +534,30 @@ impl ProviderConfig {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn probe_capacity(&self, grace: bool, grace_period_timeout_secs: u64) -> ProviderConfigAllocation {
|
||||
let guard = self.read_connection();
|
||||
if self.is_account_blocked_with_connection(&guard) {
|
||||
return ProviderConfigAllocation::Exhausted;
|
||||
}
|
||||
|
||||
if self.max_connections == 0 {
|
||||
return ProviderConfigAllocation::Available;
|
||||
}
|
||||
let connections = guard.current_connections;
|
||||
if connections < self.max_connections {
|
||||
return ProviderConfigAllocation::Available;
|
||||
}
|
||||
if !grace || connections > self.max_connections {
|
||||
return ProviderConfigAllocation::Exhausted;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if Self::grace_is_active(&guard, now, grace_period_timeout_secs) {
|
||||
return ProviderConfigAllocation::Exhausted;
|
||||
}
|
||||
ProviderConfigAllocation::GracePeriod
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
let mut guard = self.write_connection();
|
||||
// Releasing while over capacity ends the single outstanding grace grant.
|
||||
@@ -575,6 +599,14 @@ impl ProviderConfigWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probe_capacity(&self, grace: bool, grace_period_timeout_secs: u64) -> ProviderAllocation {
|
||||
match self.inner.probe_capacity(grace, grace_period_timeout_secs) {
|
||||
ProviderConfigAllocation::Available => ProviderAllocation::new_available(Arc::clone(&self.inner)),
|
||||
ProviderConfigAllocation::GracePeriod => ProviderAllocation::new_grace_period(Arc::clone(&self.inner)),
|
||||
ProviderConfigAllocation::Exhausted => ProviderAllocation::Exhausted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_next(&self, grace: bool, grace_period_timeout_secs: u64) -> Option<Arc<ProviderConfig>> {
|
||||
if self.inner.get_next(grace, grace_period_timeout_secs) {
|
||||
return Some(Arc::clone(&self.inner));
|
||||
|
||||
@@ -103,6 +103,7 @@ impl ProviderErrorKind {
|
||||
| ErrorKind::ConfigTarget
|
||||
| ErrorKind::ConfigWebUi
|
||||
| ErrorKind::TargetBouquet
|
||||
| ErrorKind::ChannelGroup
|
||||
| ErrorKind::UrlParse
|
||||
| ErrorKind::FilterParse
|
||||
| ErrorKind::RegexCompile => Self::Config,
|
||||
|
||||
@@ -538,6 +538,8 @@ impl InputWorker {
|
||||
|
||||
Self::apply_pending_generic_probe_base(collector, cluster, provider_id, &mut item);
|
||||
|
||||
let raw_video = metadata.raw_video.clone();
|
||||
let raw_audio = metadata.raw_audio.clone();
|
||||
update_properties(
|
||||
&mut item.additional_properties,
|
||||
*item_type,
|
||||
@@ -549,7 +551,17 @@ impl InputWorker {
|
||||
);
|
||||
|
||||
match item.additional_properties {
|
||||
Some(StreamProperties::Live(props)) => collector.add_live(provider_id.into(), *props),
|
||||
Some(StreamProperties::Live(props)) => {
|
||||
let bitrate_kbps = props.bitrate / 1000;
|
||||
ctx.events.observe_stream_probe(
|
||||
Arc::clone(&input.name),
|
||||
Arc::from(provider_id.to_string()),
|
||||
raw_video.as_deref(),
|
||||
raw_audio.as_deref(),
|
||||
bitrate_kbps,
|
||||
);
|
||||
collector.add_live(provider_id.into(), *props);
|
||||
}
|
||||
Some(StreamProperties::Video(props)) => collector.add_vod(provider_id.into(), *props),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -26,3 +26,7 @@ sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util"] }
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -12,3 +12,74 @@
|
||||
|
||||
pub mod transport_stream_buffer;
|
||||
pub mod ts_inspector;
|
||||
|
||||
pub const TS_PACKET_SIZE: usize = 188;
|
||||
pub const TS_SYNC_BYTE: u8 = 0x47;
|
||||
|
||||
/// Finds the first offset `o < 188` where `0x47` appears at `o`, `o + 188`, `o + 376`.
|
||||
/// With fewer than 564 bytes at EOF, all available positions must match.
|
||||
#[must_use]
|
||||
pub fn ts_sync_offset(buf: &[u8]) -> Option<usize> {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limit = TS_PACKET_SIZE.min(buf.len());
|
||||
'offset_loop: for o in 0..limit {
|
||||
let mut pos = o;
|
||||
while pos < buf.len() && pos <= o + 2 * TS_PACKET_SIZE {
|
||||
if buf[pos] != TS_SYNC_BYTE {
|
||||
continue 'offset_loop;
|
||||
}
|
||||
pos += TS_PACKET_SIZE;
|
||||
}
|
||||
return Some(o);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ts_sync_offset_empty() {
|
||||
assert_eq!(ts_sync_offset(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_sync_offset_aligned() {
|
||||
let mut buf = vec![0u8; 564];
|
||||
buf[0] = 0x47;
|
||||
buf[188] = 0x47;
|
||||
buf[376] = 0x47;
|
||||
assert_eq!(ts_sync_offset(&buf), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_sync_offset_with_garbage_prefix() {
|
||||
let mut buf = vec![0u8; 17];
|
||||
let mut ts = vec![0u8; 564];
|
||||
ts[0] = 0x47;
|
||||
ts[188] = 0x47;
|
||||
ts[376] = 0x47;
|
||||
buf.extend(ts);
|
||||
assert_eq!(ts_sync_offset(&buf), Some(17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_sync_offset_short_buffer_at_eof() {
|
||||
let mut buf = vec![0u8; 200];
|
||||
buf[10] = 0x47;
|
||||
buf[198] = 0x47;
|
||||
assert_eq!(ts_sync_offset(&buf), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_sync_offset_corrupt_packet() {
|
||||
let mut buf = vec![0u8; 564];
|
||||
buf[0] = 0x47;
|
||||
buf[188] = 0x48; // corrupt
|
||||
buf[376] = 0x47;
|
||||
assert_eq!(ts_sync_offset(&buf), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
use crate::processor::match_key::{extract_normalized_match_key, MatchKeyNormalizer};
|
||||
use indexmap::IndexMap;
|
||||
use shared::{
|
||||
model::{
|
||||
ChannelGroupOverridesDto, ChannelGroupPolicy, ChannelGroupsConfig, ChannelMemberRefDto, EpgConflictMode,
|
||||
LiveStreamProperties, PlaylistGroup, PlaylistItem, StreamProperties, UUIDType, XtreamCluster,
|
||||
},
|
||||
utils::{hash_string, token_quality, Internable},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use tuliprox_repository::{ChannelGroupOrigin, PendingChannelGroup};
|
||||
|
||||
pub(in crate::processor) fn strip_quality_words(value: &str) -> String {
|
||||
let words: Vec<&str> = value.split_whitespace().collect();
|
||||
let mut kept = Vec::with_capacity(words.len());
|
||||
for word in words {
|
||||
let core = word.trim_matches(|c: char| !c.is_alphanumeric());
|
||||
if !core.is_empty() && token_quality(core).is_some() {
|
||||
continue;
|
||||
}
|
||||
kept.push(word);
|
||||
}
|
||||
if kept.is_empty() {
|
||||
value.to_string()
|
||||
} else {
|
||||
kept.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_eligible(item: &PlaylistItem) -> bool {
|
||||
item.header.xtream_cluster == XtreamCluster::Live && !item.header.item_type.is_local()
|
||||
}
|
||||
|
||||
fn item_member_ref(item: &PlaylistItem) -> Option<ChannelMemberRefDto> {
|
||||
if !is_eligible(item) {
|
||||
return None;
|
||||
}
|
||||
let stream_id = item.header.get_input_stream_id()?;
|
||||
if stream_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ChannelMemberRefDto { input: item.header.input_name.to_string(), stream_id: stream_id.to_string() })
|
||||
}
|
||||
|
||||
struct GroupCandidate {
|
||||
key: String,
|
||||
origin: ChannelGroupOrigin,
|
||||
policy: ChannelGroupPolicy,
|
||||
positions: Vec<(usize, usize)>,
|
||||
orphaned: Vec<ChannelMemberRefDto>,
|
||||
epg_split: bool,
|
||||
name_override: Option<String>,
|
||||
logo_override: Option<String>,
|
||||
epg_override: Option<String>,
|
||||
playlist_group_override: Option<String>,
|
||||
}
|
||||
|
||||
struct AutoBucket {
|
||||
positions: Vec<(usize, usize)>,
|
||||
has_dsl_forced: bool,
|
||||
epg_split: bool,
|
||||
}
|
||||
|
||||
struct GroupSlot {
|
||||
id: u32,
|
||||
title: Arc<str>,
|
||||
xtream_cluster: XtreamCluster,
|
||||
had_channels: bool,
|
||||
channels: Vec<Option<PlaylistItem>>,
|
||||
}
|
||||
|
||||
fn build_member_index(playlist: &[PlaylistGroup]) -> HashMap<ChannelMemberRefDto, (usize, usize)> {
|
||||
let mut member_pos_map = HashMap::new();
|
||||
for (group_idx, group) in playlist.iter().enumerate() {
|
||||
if group.xtream_cluster != XtreamCluster::Live {
|
||||
continue;
|
||||
}
|
||||
for (channel_idx, channel) in group.channels.iter().enumerate() {
|
||||
if let Some(mref) = item_member_ref(channel) {
|
||||
member_pos_map.entry(mref).or_insert((group_idx, channel_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
member_pos_map
|
||||
}
|
||||
|
||||
fn resolve_manual_groups(
|
||||
config: &ChannelGroupsConfig,
|
||||
overrides: Option<&ChannelGroupOverridesDto>,
|
||||
member_pos_map: &HashMap<ChannelMemberRefDto, (usize, usize)>,
|
||||
taken_positions: &mut HashSet<(usize, usize)>,
|
||||
candidates: &mut Vec<GroupCandidate>,
|
||||
) {
|
||||
let Some(overrides) = overrides else {
|
||||
return;
|
||||
};
|
||||
for mg in &overrides.groups {
|
||||
let mut matched_positions = Vec::new();
|
||||
let mut orphaned = Vec::new();
|
||||
for mref in &mg.members {
|
||||
if let Some(&pos) = member_pos_map.get(mref) {
|
||||
if !taken_positions.contains(&pos) {
|
||||
taken_positions.insert(pos);
|
||||
matched_positions.push(pos);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::warn!(
|
||||
"Manual channel group '{}' member '{}:{}' not found or already taken",
|
||||
mg.id,
|
||||
mref.input,
|
||||
mref.stream_id
|
||||
);
|
||||
orphaned.push(mref.clone());
|
||||
}
|
||||
|
||||
if matched_positions.len() < usize::from(config.min_members) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push(GroupCandidate {
|
||||
key: mg.id.clone(),
|
||||
origin: ChannelGroupOrigin::Manual,
|
||||
policy: mg.policy.unwrap_or(config.policy_default),
|
||||
positions: matched_positions,
|
||||
orphaned,
|
||||
epg_split: false,
|
||||
name_override: Some(mg.name.clone()),
|
||||
logo_override: mg.logo.clone(),
|
||||
epg_override: mg.epg_channel_id.clone(),
|
||||
playlist_group_override: mg.playlist_group.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_auto_buckets(
|
||||
config: &ChannelGroupsConfig,
|
||||
playlist: &[PlaylistGroup],
|
||||
excluded_set: &HashSet<&ChannelMemberRefDto>,
|
||||
taken_positions: &HashSet<(usize, usize)>,
|
||||
) -> IndexMap<String, AutoBucket> {
|
||||
let normalizer = MatchKeyNormalizer::new(&config.match_normalize, config.match_as_ascii);
|
||||
let mut auto_buckets: IndexMap<String, AutoBucket> = IndexMap::new();
|
||||
|
||||
for (group_idx, group) in playlist.iter().enumerate() {
|
||||
if group.xtream_cluster != XtreamCluster::Live {
|
||||
continue;
|
||||
}
|
||||
for (channel_idx, channel) in group.channels.iter().enumerate() {
|
||||
let pos = (group_idx, channel_idx);
|
||||
if taken_positions.contains(&pos) {
|
||||
continue;
|
||||
}
|
||||
let Some(mref) = item_member_ref(channel) else {
|
||||
continue;
|
||||
};
|
||||
if excluded_set.contains(&mref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut dsl_forced = false;
|
||||
let key = match channel.header.channel_group.as_deref() {
|
||||
Some("") => continue,
|
||||
Some(forced) => {
|
||||
dsl_forced = true;
|
||||
normalizer.key(forced)
|
||||
}
|
||||
None => extract_normalized_match_key(
|
||||
&channel.header,
|
||||
config.match_by,
|
||||
config.match_as_ascii,
|
||||
Some(&normalizer),
|
||||
),
|
||||
};
|
||||
|
||||
if key.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bucket = auto_buckets.entry(key).or_insert_with(|| AutoBucket {
|
||||
positions: Vec::new(),
|
||||
has_dsl_forced: false,
|
||||
epg_split: false,
|
||||
});
|
||||
bucket.positions.push(pos);
|
||||
if dsl_forced {
|
||||
bucket.has_dsl_forced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
auto_buckets
|
||||
}
|
||||
|
||||
fn split_epg_buckets(
|
||||
playlist: &[PlaylistGroup],
|
||||
auto_buckets: IndexMap<String, AutoBucket>,
|
||||
) -> IndexMap<String, AutoBucket> {
|
||||
let mut split_buckets: IndexMap<String, AutoBucket> = IndexMap::new();
|
||||
for (key, bucket) in auto_buckets {
|
||||
if bucket.has_dsl_forced {
|
||||
split_buckets.insert(key, bucket);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut epg_groups: IndexMap<String, Vec<(usize, usize)>> = IndexMap::new();
|
||||
for &pos in &bucket.positions {
|
||||
let ch = &playlist[pos.0].channels[pos.1];
|
||||
if let Some(epg) = ch.header.epg_channel_id.as_deref() {
|
||||
let trimmed = epg.trim().to_lowercase();
|
||||
if !trimmed.is_empty() {
|
||||
epg_groups.entry(trimmed).or_default().push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if epg_groups.len() >= 2 {
|
||||
for (epg_id, sub_positions) in epg_groups {
|
||||
let sub_key = format!("{key}|{epg_id}");
|
||||
split_buckets
|
||||
.insert(sub_key, AutoBucket { positions: sub_positions, has_dsl_forced: false, epg_split: true });
|
||||
}
|
||||
} else {
|
||||
split_buckets.insert(key, bucket);
|
||||
}
|
||||
}
|
||||
split_buckets
|
||||
}
|
||||
|
||||
fn apply_auto_override(
|
||||
bucket: &mut AutoBucket,
|
||||
ao: &shared::model::AutoGroupOverrideDto,
|
||||
playlist: &[PlaylistGroup],
|
||||
member_pos_map: &HashMap<ChannelMemberRefDto, (usize, usize)>,
|
||||
taken_positions: &HashSet<(usize, usize)>,
|
||||
excluded_set: &HashSet<&ChannelMemberRefDto>,
|
||||
) {
|
||||
if !ao.remove.is_empty() {
|
||||
bucket.positions.retain(|&pos| {
|
||||
let ch = &playlist[pos.0].channels[pos.1];
|
||||
!ao.remove.iter().any(|rem| {
|
||||
ch.header.input_name.as_ref() == rem.input.as_str()
|
||||
&& ch.header.get_input_stream_id().as_deref() == Some(rem.stream_id.as_str())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for add_ref in &ao.add {
|
||||
if let Some(&pos) = member_pos_map.get(add_ref) {
|
||||
if !taken_positions.contains(&pos) && !excluded_set.contains(add_ref) && !bucket.positions.contains(&pos) {
|
||||
bucket.positions.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !ao.order.is_empty() {
|
||||
let mut ordered = Vec::with_capacity(bucket.positions.len());
|
||||
let mut used = HashSet::new();
|
||||
|
||||
for ord_ref in &ao.order {
|
||||
if let Some(&pos) = bucket.positions.iter().find(|&&pos| {
|
||||
let ch = &playlist[pos.0].channels[pos.1];
|
||||
ch.header.input_name.as_ref() == ord_ref.input.as_str()
|
||||
&& ch.header.get_input_stream_id().as_deref() == Some(ord_ref.stream_id.as_str())
|
||||
}) {
|
||||
if used.insert(pos) {
|
||||
ordered.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
for &pos in &bucket.positions {
|
||||
if !used.contains(&pos) {
|
||||
ordered.push(pos);
|
||||
}
|
||||
}
|
||||
bucket.positions = ordered;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_group_metadata(
|
||||
candidate: &GroupCandidate,
|
||||
rep_header: &shared::model::PlaylistItemHeader,
|
||||
slots: &[GroupSlot],
|
||||
config: &ChannelGroupsConfig,
|
||||
group_uuid: UUIDType,
|
||||
) -> shared::model::PlaylistItemHeader {
|
||||
let mut group_header = rep_header.clone();
|
||||
group_header.uuid = group_uuid;
|
||||
group_header.channel_group = None;
|
||||
group_header.channel_group_uuid = Some(group_uuid);
|
||||
|
||||
if let Some(name) = &candidate.name_override {
|
||||
group_header.name = name.as_str().intern();
|
||||
group_header.title = name.as_str().intern();
|
||||
} else if config.name_strip_quality {
|
||||
group_header.name = strip_quality_words(&rep_header.name).intern();
|
||||
group_header.title = strip_quality_words(&rep_header.title).intern();
|
||||
}
|
||||
|
||||
if let Some(logo) = &candidate.logo_override {
|
||||
group_header.logo = logo.as_str().intern();
|
||||
} else if group_header.logo.is_empty() {
|
||||
for &pos in &candidate.positions {
|
||||
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
|
||||
if !item.header.logo.is_empty() {
|
||||
group_header.logo = item.header.logo.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(epg) = &candidate.epg_override {
|
||||
group_header.epg_channel_id = Some(epg.as_str().intern());
|
||||
} else if group_header.epg_channel_id.as_deref().is_none_or(|s| s.trim().is_empty()) {
|
||||
for &pos in &candidate.positions {
|
||||
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
|
||||
if let Some(epg) = &item.header.epg_channel_id {
|
||||
if !epg.trim().is_empty() {
|
||||
group_header.epg_channel_id = Some(epg.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_archive_metadata(&mut group_header, candidate, slots);
|
||||
group_header
|
||||
}
|
||||
|
||||
fn apply_archive_metadata(
|
||||
group_header: &mut shared::model::PlaylistItemHeader,
|
||||
candidate: &GroupCandidate,
|
||||
slots: &[GroupSlot],
|
||||
) {
|
||||
let mut has_archive = false;
|
||||
let mut max_archive_duration: Option<i32> = None;
|
||||
for &pos in &candidate.positions {
|
||||
if let Some(item) = slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref) {
|
||||
if let Some(StreamProperties::Live(live)) = &item.header.additional_properties {
|
||||
if live.tv_archive.unwrap_or(0) > 0 {
|
||||
has_archive = true;
|
||||
if let Some(dur) = live.tv_archive_duration {
|
||||
max_archive_duration = Some(max_archive_duration.map_or(dur, |m| m.max(dur)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_archive {
|
||||
match &mut group_header.additional_properties {
|
||||
Some(StreamProperties::Live(live)) => {
|
||||
live.tv_archive = Some(1);
|
||||
live.tv_archive_duration = max_archive_duration;
|
||||
}
|
||||
_ => {
|
||||
group_header.additional_properties = Some(StreamProperties::Live(Box::new(LiveStreamProperties {
|
||||
name: group_header.name.clone(),
|
||||
category_id: group_header.category_id,
|
||||
stream_id: 0,
|
||||
stream_icon: group_header.logo.clone(),
|
||||
direct_source: "".intern(),
|
||||
custom_sid: None,
|
||||
added: None,
|
||||
stream_type: None,
|
||||
epg_channel_id: group_header.epg_channel_id.clone(),
|
||||
tv_archive: Some(1),
|
||||
tv_archive_duration: max_archive_duration,
|
||||
is_adult: 0,
|
||||
..Default::default()
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_group_candidates(
|
||||
config: &ChannelGroupsConfig,
|
||||
overrides: Option<&ChannelGroupOverridesDto>,
|
||||
playlist: &[PlaylistGroup],
|
||||
) -> Vec<GroupCandidate> {
|
||||
let member_pos_map = build_member_index(playlist);
|
||||
let mut taken_positions = HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
resolve_manual_groups(config, overrides, &member_pos_map, &mut taken_positions, &mut candidates);
|
||||
|
||||
let excluded_set: HashSet<&ChannelMemberRefDto> =
|
||||
overrides.map_or_else(HashSet::new, |o| o.excluded.iter().collect());
|
||||
|
||||
let mut auto_buckets = collect_auto_buckets(config, playlist, &excluded_set, &taken_positions);
|
||||
|
||||
if config.epg_conflict == EpgConflictMode::Split {
|
||||
auto_buckets = split_epg_buckets(playlist, auto_buckets);
|
||||
}
|
||||
|
||||
for (key, mut bucket) in auto_buckets {
|
||||
let auto_override = overrides.and_then(|o| o.auto_overrides.iter().find(|ao| ao.auto_key == key));
|
||||
let origin = if auto_override.is_some() { ChannelGroupOrigin::AutoEdited } else { ChannelGroupOrigin::Auto };
|
||||
|
||||
if let Some(ao) = auto_override {
|
||||
apply_auto_override(&mut bucket, ao, playlist, &member_pos_map, &taken_positions, &excluded_set);
|
||||
}
|
||||
|
||||
if bucket.positions.len() < usize::from(config.min_members) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for &pos in &bucket.positions {
|
||||
taken_positions.insert(pos);
|
||||
}
|
||||
|
||||
candidates.push(GroupCandidate {
|
||||
key: key.clone(),
|
||||
origin,
|
||||
policy: auto_override.and_then(|ao| ao.policy).unwrap_or(config.policy_default),
|
||||
positions: bucket.positions,
|
||||
orphaned: Vec::new(),
|
||||
epg_split: bucket.epg_split,
|
||||
name_override: auto_override.and_then(|ao| ao.name.clone()),
|
||||
logo_override: auto_override.and_then(|ao| ao.logo.clone()),
|
||||
epg_override: auto_override.and_then(|ao| ao.epg_channel_id.clone()),
|
||||
playlist_group_override: auto_override.and_then(|ao| ao.playlist_group.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn rebuild_playlist_from_slots(
|
||||
slots: Vec<GroupSlot>,
|
||||
deferred_placements: Vec<(String, PlaylistItem)>,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
) {
|
||||
for slot in slots {
|
||||
let channels: Vec<PlaylistItem> = slot.channels.into_iter().flatten().collect();
|
||||
if channels.is_empty() && slot.had_channels {
|
||||
continue;
|
||||
}
|
||||
playlist.push(PlaylistGroup { id: slot.id, title: slot.title, channels, xtream_cluster: slot.xtream_cluster });
|
||||
}
|
||||
|
||||
for (pg_override, mut item) in deferred_placements {
|
||||
if let Some(g) = playlist.iter_mut().find(|g| g.title.as_ref() == pg_override.as_str()) {
|
||||
item.header.category_id = g.id;
|
||||
item.header.group = g.title.clone();
|
||||
g.channels.push(item);
|
||||
} else {
|
||||
let next_id = playlist.iter().map(|g| g.id).max().unwrap_or(0) + 1;
|
||||
item.header.category_id = next_id;
|
||||
item.header.group = pg_override.as_str().intern();
|
||||
playlist.push(PlaylistGroup {
|
||||
id: next_id,
|
||||
title: pg_override.as_str().intern(),
|
||||
channels: vec![item],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_groups(
|
||||
candidates: &[GroupCandidate],
|
||||
config: &ChannelGroupsConfig,
|
||||
target_name: &str,
|
||||
mut slots: Vec<GroupSlot>,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
) -> Vec<PendingChannelGroup> {
|
||||
let mut deferred_placements = Vec::new();
|
||||
let mut pending_groups = Vec::with_capacity(candidates.len());
|
||||
|
||||
for candidate in candidates {
|
||||
let rep_pos = candidate.positions[0];
|
||||
let Some(rep_item) = slots.get(rep_pos.0).and_then(|g| g.channels.get(rep_pos.1)).and_then(Option::as_ref)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let group_uuid = hash_string(&format!("channel-group|{target_name}|{}", candidate.key));
|
||||
let group_header = build_group_metadata(candidate, &rep_item.header, &slots, config, group_uuid);
|
||||
|
||||
let mut members = Vec::with_capacity(candidate.positions.len());
|
||||
for &pos in &candidate.positions {
|
||||
if let Some(item) = slots.get_mut(pos.0).and_then(|g| g.channels.get_mut(pos.1)).and_then(Option::take) {
|
||||
members.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let group_item = PlaylistItem { header: group_header };
|
||||
|
||||
if let Some(pg_override) = &candidate.playlist_group_override {
|
||||
deferred_placements.push((pg_override.clone(), group_item));
|
||||
} else if let Some(slot) = slots.get_mut(rep_pos.0).and_then(|g| g.channels.get_mut(rep_pos.1)) {
|
||||
*slot = Some(group_item);
|
||||
}
|
||||
|
||||
pending_groups.push(PendingChannelGroup {
|
||||
group_uuid,
|
||||
key: candidate.key.as_str().intern(),
|
||||
origin: candidate.origin,
|
||||
policy: candidate.policy,
|
||||
members,
|
||||
orphaned: candidate.orphaned.clone(),
|
||||
epg_split: candidate.epg_split,
|
||||
});
|
||||
}
|
||||
|
||||
rebuild_playlist_from_slots(slots, deferred_placements, playlist);
|
||||
pending_groups
|
||||
}
|
||||
|
||||
pub(in crate::processor) fn group_playlist(
|
||||
config: &ChannelGroupsConfig,
|
||||
overrides: Option<&ChannelGroupOverridesDto>,
|
||||
target_name: &str,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
) -> Vec<PendingChannelGroup> {
|
||||
if !config.enabled || config.min_members < 2 || playlist.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let candidates = collect_group_candidates(config, overrides, playlist);
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let slots: Vec<GroupSlot> = playlist
|
||||
.drain(..)
|
||||
.map(|g| GroupSlot {
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
xtream_cluster: g.xtream_cluster,
|
||||
had_channels: !g.channels.is_empty(),
|
||||
channels: g.channels.into_iter().map(Some).collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
materialize_groups(&candidates, config, target_name, slots, playlist)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod assemble;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(in crate::processor) use assemble::*;
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::*;
|
||||
use shared::{
|
||||
model::{
|
||||
AutoGroupOverrideDto, ChannelGroupOverridesDto, ChannelGroupsConfig, ChannelMemberRefDto, EpgConflictMode,
|
||||
LiveStreamProperties, ManualChannelGroupDto, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
|
||||
StreamProperties, XtreamCluster,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use tuliprox_repository::ChannelGroupOrigin;
|
||||
|
||||
fn live(input: &str, stream_id: &str, title: &str) -> PlaylistItem {
|
||||
let mut header = PlaylistItemHeader {
|
||||
input_name: input.intern(),
|
||||
id: stream_id.intern(),
|
||||
title: title.intern(),
|
||||
name: title.intern(),
|
||||
url: format!("http://{input}.invalid/live/u/p/{stream_id}.ts").intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..Default::default()
|
||||
};
|
||||
header.freeze_input_stream_id();
|
||||
header.gen_uuid();
|
||||
PlaylistItem { header }
|
||||
}
|
||||
|
||||
fn group(title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
|
||||
PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live }
|
||||
}
|
||||
|
||||
fn cfg() -> ChannelGroupsConfig { serde_json::from_str(r#"{"enabled":true}"#).unwrap() }
|
||||
|
||||
fn with_epg(mut item: PlaylistItem, epg: &str) -> PlaylistItem {
|
||||
item.header.epg_channel_id = Some(epg.intern());
|
||||
item
|
||||
}
|
||||
|
||||
fn overrides_with_manual(id: &str, members: &[(&str, &str)]) -> ChannelGroupOverridesDto {
|
||||
let mut overrides = ChannelGroupOverridesDto::new("T");
|
||||
overrides.groups.push(ManualChannelGroupDto {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
members: members
|
||||
.iter()
|
||||
.map(|(input, stream_id)| ChannelMemberRefDto { input: (*input).into(), stream_id: (*stream_id).into() })
|
||||
.collect(),
|
||||
..Default::default()
|
||||
});
|
||||
overrides
|
||||
}
|
||||
|
||||
fn set_archive(item: &mut PlaylistItem, duration: i32) {
|
||||
let props = LiveStreamProperties { tv_archive: Some(1), tv_archive_duration: Some(duration), ..Default::default() };
|
||||
item.header.additional_properties = Some(StreamProperties::Live(Box::new(props)));
|
||||
}
|
||||
|
||||
fn archive_of(item: &PlaylistItem) -> Option<i32> {
|
||||
match &item.header.additional_properties {
|
||||
Some(StreamProperties::Live(live)) => live.tv_archive_duration,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_groups_same_caption_across_inputs() {
|
||||
let mut playlist = vec![group(
|
||||
"News",
|
||||
vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD"), live("solis", "2", "ARD")],
|
||||
)];
|
||||
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].members.len(), 2);
|
||||
assert_eq!(pending[0].origin, ChannelGroupOrigin::Auto);
|
||||
let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect();
|
||||
assert_eq!(titles, vec!["ZDF HD", "ARD"]); // group item at representative position
|
||||
assert_eq!(playlist[0].channels[0].header.uuid, pending[0].group_uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_member_key_is_not_grouped() {
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("solis", "2", "ARD")])];
|
||||
assert!(group_playlist(&cfg(), None, "T", &mut playlist).is_empty());
|
||||
assert_eq!(playlist[0].channels.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_opt_out_and_forced_key() {
|
||||
let mut a = live("solis", "1", "ZDF HD");
|
||||
a.header.channel_group = Some("".intern());
|
||||
let mut b = live("korath", "7", "Zweites");
|
||||
b.header.channel_group = Some("zdf hd".intern());
|
||||
let c = live("veyra", "3", "ZDF");
|
||||
let mut playlist = vec![group("News", vec![a, b, c])];
|
||||
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let inputs: Vec<_> = pending[0].members.iter().map(|m| m.header.input_name.to_string()).collect();
|
||||
assert_eq!(inputs, vec!["korath", "veyra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_group_wins_over_auto_and_keeps_order() {
|
||||
let overrides = overrides_with_manual("zdf", &[("korath", "7"), ("solis", "1")]);
|
||||
let mut playlist = vec![group(
|
||||
"News",
|
||||
vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD"), live("veyra", "3", "ZDF")],
|
||||
)];
|
||||
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
|
||||
let manual = pending.iter().find(|p| p.origin == ChannelGroupOrigin::Manual).expect("manual");
|
||||
let inputs: Vec<_> = manual.members.iter().map(|m| m.header.input_name.to_string()).collect();
|
||||
assert_eq!(inputs, vec!["korath", "solis"]);
|
||||
// veyra alone is below min_members and stays a normal channel
|
||||
assert!(playlist[0].channels.iter().any(|c| c.header.input_name.as_ref() == "veyra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_member_never_grouped() {
|
||||
let mut overrides = ChannelGroupOverridesDto::new("T");
|
||||
overrides.excluded.push(ChannelMemberRefDto { input: "solis".into(), stream_id: "1".into() });
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
|
||||
assert!(group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_override_orders_and_marks_edited() {
|
||||
let mut overrides = ChannelGroupOverridesDto::new("T");
|
||||
overrides.auto_overrides.push(AutoGroupOverrideDto {
|
||||
auto_key: "zdf".into(),
|
||||
order: vec![ChannelMemberRefDto { input: "korath".into(), stream_id: "7".into() }],
|
||||
..Default::default()
|
||||
});
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
|
||||
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
|
||||
assert_eq!(pending[0].origin, ChannelGroupOrigin::AutoEdited);
|
||||
assert_eq!(pending[0].members[0].header.input_name.as_ref(), "korath");
|
||||
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "ZDF FHD"); // representative
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphaned_manual_member_is_skipped() {
|
||||
let overrides = overrides_with_manual("zdf", &[("solis", "1"), ("korath", "7"), ("gone", "99")]);
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
|
||||
let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist);
|
||||
assert_eq!(pending[0].members.len(), 2);
|
||||
assert_eq!(pending[0].orphaned, vec![ChannelMemberRefDto { input: "gone".into(), stream_id: "99".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_group_below_min_members_dissolves() {
|
||||
let overrides = overrides_with_manual("zdf", &[("solis", "1"), ("gone", "99")]);
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("veyra", "2", "ARD")])];
|
||||
assert!(group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).is_empty());
|
||||
assert_eq!(playlist[0].channels.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_is_max_of_members() {
|
||||
let mut a = live("solis", "1", "ZDF HD");
|
||||
set_archive(&mut a, 3);
|
||||
let mut b = live("korath", "7", "ZDF FHD");
|
||||
set_archive(&mut b, 7);
|
||||
let mut playlist = vec![group("News", vec![a, b])];
|
||||
group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!(archive_of(&playlist[0].channels[0]), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vod_and_series_are_never_grouped() {
|
||||
let mut a = live("solis", "1", "Film");
|
||||
a.header.item_type = PlaylistItemType::Video;
|
||||
a.header.xtream_cluster = XtreamCluster::Video;
|
||||
let mut b = live("korath", "2", "Film");
|
||||
b.header.item_type = PlaylistItemType::Video;
|
||||
b.header.xtream_cluster = XtreamCluster::Video;
|
||||
let mut playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![a, b],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
assert!(group_playlist(&cfg(), None, "T", &mut playlist).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_epg_ids_split_the_auto_bucket() {
|
||||
let mut playlist = vec![group(
|
||||
"Sport",
|
||||
vec![
|
||||
with_epg(live("solis", "1", "Sport 1 HD"), "sport1.de"),
|
||||
with_epg(live("korath", "7", "Sport 1"), "sport1.de"),
|
||||
with_epg(live("veyra", "3", "Sport 1 FHD"), "sport1.uk"),
|
||||
with_epg(live("orun", "4", "Sport 1"), "sport1.uk"),
|
||||
live("solis", "9", "Sport 1"), // no EPG id → stays a normal channel
|
||||
],
|
||||
)];
|
||||
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!(pending.len(), 2);
|
||||
assert!(pending.iter().all(|p| p.epg_split && p.members.len() == 2));
|
||||
assert!(playlist[0]
|
||||
.channels
|
||||
.iter()
|
||||
.any(|c| c.header.input_name.as_ref() == "solis" && c.header.id.as_ref() == "9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epg_conflict_ignore_keeps_one_group() {
|
||||
let mut config = cfg();
|
||||
config.epg_conflict = EpgConflictMode::Ignore;
|
||||
let mut playlist = vec![group(
|
||||
"Sport",
|
||||
vec![
|
||||
with_epg(live("solis", "1", "Sport 1"), "sport1.de"),
|
||||
with_epg(live("korath", "7", "Sport 1"), "sport1.uk"),
|
||||
],
|
||||
)];
|
||||
let pending = group_playlist(&config, None, "T", &mut playlist);
|
||||
assert_eq!((pending.len(), pending[0].epg_split), (1, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_forced_key_is_never_split() {
|
||||
let mut a = with_epg(live("solis", "1", "Eins"), "a.de");
|
||||
a.header.channel_group = Some("eins".intern());
|
||||
let mut b = with_epg(live("korath", "7", "One"), "b.uk");
|
||||
b.header.channel_group = Some("eins".intern());
|
||||
let mut playlist = vec![group("News", vec![a, b])];
|
||||
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!((pending.len(), pending[0].members.len()), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_normalize_groups_prefixed_and_unprefixed_captions() {
|
||||
let mut config = cfg();
|
||||
config.match_normalize.strip_regex = Some(r"^\s*(?:\|[A-Z]{2,3}\||[A-Z]{2,3}\s*[:|])\s*".to_string());
|
||||
config.match_normalize.ignore_tokens = vec!["hevc".to_string()];
|
||||
config.prepare().expect("valid");
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "DE: ZDF HEVC"), live("korath", "7", "ZDF")])];
|
||||
let pending = group_playlist(&config, None, "T", &mut playlist);
|
||||
assert_eq!((pending.len(), pending[0].members.len()), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_strip_quality_cleans_derived_name_only() {
|
||||
assert_eq!(strip_quality_words("ZDF [FHD]"), "ZDF");
|
||||
assert_eq!(strip_quality_words("DE: ZDF HD"), "DE: ZDF");
|
||||
assert_eq!(strip_quality_words("ZDF-HD"), "ZDF-HD");
|
||||
assert_eq!(strip_quality_words("HD"), "HD"); // nothing left → original
|
||||
let mut config = cfg();
|
||||
config.name_strip_quality = true;
|
||||
let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
|
||||
group_playlist(&config, None, "T", &mut playlist);
|
||||
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "ZDF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptied_playlist_group_is_dropped_and_group_uuid_is_stable() {
|
||||
let build =
|
||||
|| vec![group("A", vec![live("solis", "1", "ZDF HD")]), group("B", vec![live("korath", "7", "ZDF FHD")])];
|
||||
let mut first = build();
|
||||
let p1 = group_playlist(&cfg(), None, "T", &mut first);
|
||||
let mut second = build();
|
||||
let p2 = group_playlist(&cfg(), None, "T", &mut second);
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(p1[0].group_uuid, p2[0].group_uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_keep_original_uuid_and_group_item_is_marked() {
|
||||
let a = live("solis", "1", "ZDF HD");
|
||||
let b = live("korath", "7", "ZDF FHD");
|
||||
let (uuid_a, uuid_b) = (a.header.uuid, b.header.uuid);
|
||||
let mut playlist = vec![group("News", vec![a, b])];
|
||||
let pending = group_playlist(&cfg(), None, "T", &mut playlist);
|
||||
assert_eq!(pending[0].members.iter().map(|m| m.header.uuid).collect::<Vec<_>>(), vec![uuid_a, uuid_b]);
|
||||
assert_eq!(playlist[0].channels[0].header.channel_group_uuid, Some(pending[0].group_uuid));
|
||||
}
|
||||
@@ -1,45 +1,20 @@
|
||||
use super::match_key::{dedup_match_by, extract_normalized_match_key, raw_match_value};
|
||||
use shared::{
|
||||
model::{DeduplicateConfig, DeduplicateKeep, DeduplicateMatchBy, PlaylistGroup, PlaylistItem, XtreamCluster},
|
||||
utils::{deunicode_string, quality_rank, quality_tokens, token_quality},
|
||||
model::{DeduplicateConfig, DeduplicateKeep, PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster},
|
||||
utils::quality_rank,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Lowercased token join with quality tokens removed, so "News HD" and
|
||||
/// "NEWS [FHD]" produce the same key.
|
||||
fn normalized_dedup_key(value: &str, match_as_ascii: bool) -> String {
|
||||
let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) };
|
||||
let mut key = String::with_capacity(value.len());
|
||||
for token in quality_tokens(&value) {
|
||||
if token_quality(token).is_some() {
|
||||
continue;
|
||||
}
|
||||
if !key.is_empty() {
|
||||
key.push(' ');
|
||||
}
|
||||
for ch in token.chars() {
|
||||
key.extend(ch.to_lowercase());
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
fn match_value(config: DeduplicateConfig, item: &PlaylistItem) -> String {
|
||||
normalized_dedup_key(raw_match_value(config, item), config.match_as_ascii)
|
||||
extract_normalized_match_key(&item.header, dedup_match_by(config.match_by), config.match_as_ascii, None)
|
||||
}
|
||||
|
||||
fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
|
||||
let header = &item.header;
|
||||
match config.match_by {
|
||||
DeduplicateMatchBy::Caption => {
|
||||
if header.title.is_empty() {
|
||||
header.name.as_ref()
|
||||
} else {
|
||||
header.title.as_ref()
|
||||
}
|
||||
}
|
||||
DeduplicateMatchBy::Name => header.name.as_ref(),
|
||||
DeduplicateMatchBy::Title => header.title.as_ref(),
|
||||
}
|
||||
fn item_raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
|
||||
raw_match_value(&item.header, dedup_match_by(config.match_by))
|
||||
}
|
||||
|
||||
fn should_skip(channel: &PlaylistItem, skip: &HashSet<UUIDType>) -> bool {
|
||||
skip.contains(&channel.header.uuid) || channel.header.channel_group_uuid.is_some()
|
||||
}
|
||||
|
||||
/// Collapse duplicate channels across the whole target playlist (per cluster).
|
||||
@@ -48,16 +23,20 @@ fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
|
||||
pub(in crate::processor) fn deduplicate_playlist(
|
||||
config: DeduplicateConfig,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
skip: &HashSet<UUIDType>,
|
||||
) -> usize {
|
||||
// winner per key: (quality rank, group index, channel index)
|
||||
let mut winners: HashMap<(XtreamCluster, String), (u8, usize, usize)> = HashMap::new();
|
||||
for (group_idx, group) in playlist.iter().enumerate() {
|
||||
for (channel_idx, channel) in group.channels.iter().enumerate() {
|
||||
if should_skip(channel, skip) {
|
||||
continue;
|
||||
}
|
||||
let key_value = match_value(config, channel);
|
||||
if key_value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rank = quality_rank(raw_match_value(config, channel));
|
||||
let rank = quality_rank(item_raw_match_value(config, channel));
|
||||
match winners.entry((group.xtream_cluster, key_value)) {
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
entry.insert((rank, group_idx, channel_idx));
|
||||
@@ -81,6 +60,9 @@ pub(in crate::processor) fn deduplicate_playlist(
|
||||
group.channels.retain(|channel| {
|
||||
let idx = channel_idx;
|
||||
channel_idx += 1;
|
||||
if should_skip(channel, skip) {
|
||||
return true;
|
||||
}
|
||||
let key_value = match_value(config, channel);
|
||||
if key_value.is_empty() {
|
||||
return true;
|
||||
@@ -104,7 +86,10 @@ pub(in crate::processor) fn deduplicate_playlist(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::{model::PlaylistItemHeader, utils::Internable};
|
||||
use shared::{
|
||||
model::{DeduplicateMatchBy, PlaylistItemHeader},
|
||||
utils::Internable,
|
||||
};
|
||||
|
||||
fn make_item(title: &str) -> PlaylistItem {
|
||||
PlaylistItem { header: PlaylistItemHeader { title: title.intern(), ..Default::default() } }
|
||||
@@ -134,7 +119,7 @@ mod tests {
|
||||
keep: DeduplicateKeep::BestQuality,
|
||||
match_as_ascii: false,
|
||||
};
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
|
||||
assert_eq!(removed, 2);
|
||||
let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect();
|
||||
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
|
||||
@@ -150,7 +135,7 @@ mod tests {
|
||||
match_as_ascii: false,
|
||||
};
|
||||
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
|
||||
|
||||
// Target-wide curation evaluates only this surviving winner.
|
||||
assert_eq!(removed, 1);
|
||||
@@ -167,7 +152,7 @@ mod tests {
|
||||
keep: DeduplicateKeep::First,
|
||||
match_as_ascii: false,
|
||||
};
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "News HD");
|
||||
}
|
||||
@@ -184,7 +169,7 @@ mod tests {
|
||||
keep: DeduplicateKeep::BestQuality,
|
||||
match_as_ascii: false,
|
||||
};
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
|
||||
assert_eq!(removed, 1);
|
||||
// "A" was emptied by dedup and dropped, the already-empty group survives
|
||||
assert_eq!(playlist.len(), 2);
|
||||
@@ -203,7 +188,7 @@ mod tests {
|
||||
keep: DeduplicateKeep::BestQuality,
|
||||
match_as_ascii: true,
|
||||
};
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
let removed = deduplicate_playlist(config, &mut playlist, &HashSet::new());
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Cafe FHD");
|
||||
|
||||
@@ -214,6 +199,17 @@ mod tests {
|
||||
keep: DeduplicateKeep::BestQuality,
|
||||
match_as_ascii: false,
|
||||
};
|
||||
assert_eq!(deduplicate_playlist(config, &mut playlist), 0);
|
||||
assert_eq!(deduplicate_playlist(config, &mut playlist, &HashSet::new()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_never_drops_group_item() {
|
||||
let group_item = make_item("ZDF HD");
|
||||
let excluded = make_item("ZDF UHD");
|
||||
let skip: HashSet<UUIDType> = [group_item.header.uuid].into_iter().collect();
|
||||
let mut playlist = vec![make_group("News", vec![group_item, excluded])];
|
||||
let removed = deduplicate_playlist(DeduplicateConfig::default(), &mut playlist, &skip);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(playlist[0].channels.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
use regex::Regex;
|
||||
use shared::{
|
||||
model::{ChannelMatchBy, ChannelMatchNormalize, DeduplicateMatchBy, PlaylistItemHeader},
|
||||
utils::{deunicode_string, quality_tokens, token_quality},
|
||||
};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
|
||||
pub(in crate::processor) const fn dedup_match_by(value: DeduplicateMatchBy) -> ChannelMatchBy {
|
||||
match value {
|
||||
DeduplicateMatchBy::Caption => ChannelMatchBy::Caption,
|
||||
DeduplicateMatchBy::Name => ChannelMatchBy::Name,
|
||||
DeduplicateMatchBy::Title => ChannelMatchBy::Title,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercased token join with quality tokens removed, so "News HD" and
|
||||
/// "NEWS [FHD]" produce the same key.
|
||||
pub(in crate::processor) fn normalized_match_key(value: &str, match_as_ascii: bool) -> String {
|
||||
let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) };
|
||||
let mut key = String::with_capacity(value.len());
|
||||
for token in quality_tokens(&value) {
|
||||
if token_quality(token).is_some() {
|
||||
continue;
|
||||
}
|
||||
if !key.is_empty() {
|
||||
key.push(' ');
|
||||
}
|
||||
for ch in token.chars() {
|
||||
key.extend(ch.to_lowercase());
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
pub(in crate::processor) fn raw_match_value(header: &PlaylistItemHeader, match_by: ChannelMatchBy) -> &str {
|
||||
match match_by {
|
||||
ChannelMatchBy::Caption => {
|
||||
if header.title.is_empty() {
|
||||
header.name.as_ref()
|
||||
} else {
|
||||
header.title.as_ref()
|
||||
}
|
||||
}
|
||||
ChannelMatchBy::Name => header.name.as_ref(),
|
||||
ChannelMatchBy::Title => header.title.as_ref(),
|
||||
ChannelMatchBy::EpgChannelId => header.epg_channel_id.as_deref().unwrap_or(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::processor) fn extract_normalized_match_key(
|
||||
header: &PlaylistItemHeader,
|
||||
match_by: ChannelMatchBy,
|
||||
match_as_ascii: bool,
|
||||
normalizer: Option<&MatchKeyNormalizer<'_>>,
|
||||
) -> String {
|
||||
let raw = raw_match_value(header, match_by);
|
||||
if match_by == ChannelMatchBy::EpgChannelId {
|
||||
return raw.trim().to_lowercase();
|
||||
}
|
||||
match normalizer {
|
||||
Some(normalizer) => normalizer.key(raw),
|
||||
None => normalized_match_key(raw, match_as_ascii),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(in crate::processor) struct MatchKeyNormalizer<'a> {
|
||||
strip: Option<&'a Regex>,
|
||||
ignore: HashSet<String>,
|
||||
aliases: HashMap<String, String>,
|
||||
match_as_ascii: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<'a> MatchKeyNormalizer<'a> {
|
||||
pub(in crate::processor) fn new(config: &'a ChannelMatchNormalize, match_as_ascii: bool) -> Self {
|
||||
let mut normalizer = Self {
|
||||
strip: config.t_strip_regex.as_deref(),
|
||||
ignore: config.ignore_tokens.iter().map(|token| token.to_lowercase()).collect(),
|
||||
aliases: HashMap::new(),
|
||||
match_as_ascii,
|
||||
};
|
||||
let aliases =
|
||||
config.aliases.iter().map(|(from, to)| (normalizer.base_key(from), normalizer.base_key(to))).collect();
|
||||
normalizer.aliases = aliases;
|
||||
normalizer
|
||||
}
|
||||
|
||||
pub(in crate::processor) fn key(&self, value: &str) -> String {
|
||||
let key = self.base_key(value);
|
||||
self.aliases.get(&key).cloned().unwrap_or(key)
|
||||
}
|
||||
|
||||
fn base_key(&self, value: &str) -> String {
|
||||
let stripped = self.strip.map_or(Cow::Borrowed(value), |regex| regex.replace_all(value, ""));
|
||||
let folded = if self.match_as_ascii { deunicode_string(&stripped) } else { stripped };
|
||||
let mut key = String::with_capacity(folded.len());
|
||||
for token in quality_tokens(&folded) {
|
||||
let lower = token.to_lowercase();
|
||||
if token_quality(token).is_some() || self.ignore.contains(&lower) {
|
||||
continue;
|
||||
}
|
||||
if !key.is_empty() {
|
||||
key.push(' ');
|
||||
}
|
||||
key.push_str(&lower);
|
||||
}
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::{
|
||||
model::{ChannelGroupsConfig, ChannelMatchNormalize, PlaylistItemHeader},
|
||||
utils::Internable,
|
||||
};
|
||||
|
||||
fn header(title: &str, name: &str, epg: Option<&str>) -> PlaylistItemHeader {
|
||||
PlaylistItemHeader {
|
||||
title: title.intern(),
|
||||
name: name.intern(),
|
||||
epg_channel_id: epg.map(Internable::intern),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_quality_tokens_and_lowercases() {
|
||||
assert_eq!(normalized_match_key("News HD", false), "news");
|
||||
assert_eq!(normalized_match_key("NEWS [FHD]", false), normalized_match_key("news 1080p", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_falls_back_to_name() {
|
||||
assert_eq!(
|
||||
extract_normalized_match_key(&header("", "Sport HD", None), ChannelMatchBy::Caption, false, None),
|
||||
"sport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epg_channel_id_is_used_verbatim_lowercased() {
|
||||
let h = header("ZDF HD", "ZDF", Some("ZDF.de"));
|
||||
assert_eq!(extract_normalized_match_key(&h, ChannelMatchBy::EpgChannelId, false, None), "zdf.de");
|
||||
assert_eq!(
|
||||
extract_normalized_match_key(&header("X", "X", None), ChannelMatchBy::EpgChannelId, false, None),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_folding() {
|
||||
assert_eq!(normalized_match_key("Café HD", true), normalized_match_key("Cafe FHD", true));
|
||||
}
|
||||
|
||||
fn normalize(strip: Option<&str>, ignore: &[&str], aliases: &[(&str, &str)]) -> ChannelMatchNormalize {
|
||||
let mut cfg: ChannelGroupsConfig = serde_json::from_str(r#"{"enabled":true}"#).expect("parses");
|
||||
cfg.match_normalize = ChannelMatchNormalize {
|
||||
strip_regex: strip.map(str::to_string),
|
||||
ignore_tokens: ignore.iter().map(|t| (*t).to_string()).collect(),
|
||||
aliases: aliases.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
cfg.prepare().expect("valid");
|
||||
cfg.match_normalize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizer_strips_prefix_ignores_tokens_and_resolves_aliases() {
|
||||
let cfg = normalize(
|
||||
Some(r"^\s*(?:\|[A-Z]{2,3}\||[A-Z]{2,3}\s*[:|])\s*"),
|
||||
&["HEVC", "50fps"],
|
||||
&[("Das Erste HD", "ARD")],
|
||||
);
|
||||
let normalizer = MatchKeyNormalizer::new(&cfg, false);
|
||||
assert_eq!(normalizer.key("DE: ZDF HEVC"), "zdf");
|
||||
assert_eq!(normalizer.key("|DE| ZDF 50FPS"), "zdf");
|
||||
assert_eq!(normalizer.key("ZDF"), "zdf");
|
||||
assert_eq!(normalizer.key("ZDF NEO"), "zdf neo");
|
||||
assert_eq!(normalizer.key("DE: Das Erste"), "ard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_keys_unchanged_without_normalizer() {
|
||||
let h = header("DE: ZDF HEVC", "", None);
|
||||
assert_eq!(extract_normalized_match_key(&h, ChannelMatchBy::Caption, false, None), "de zdf hevc");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ mod stalker;
|
||||
mod stalker_refresh;
|
||||
mod xtream;
|
||||
// mod affix;
|
||||
pub(in crate::processor) mod channel_group;
|
||||
mod deduplicate;
|
||||
mod epg;
|
||||
mod library;
|
||||
pub(in crate::processor) mod match_key;
|
||||
mod probe_handle_guard;
|
||||
mod providers;
|
||||
mod resolve_options;
|
||||
|
||||
@@ -59,8 +59,8 @@ use tuliprox_curation::{
|
||||
SelectorOutcome,
|
||||
};
|
||||
use tuliprox_repository::{
|
||||
persist_playlist_views, CategoryKey, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource,
|
||||
PlaylistStorageState, TargetPlaylistPersistOptions,
|
||||
persist_playlist_views_with_groups, CategoryKey, MemoryPlaylistSource, PendingChannelGroup,
|
||||
PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState, TargetPlaylistPersistOptions,
|
||||
};
|
||||
use tuliprox_session::ActiveProviderManager;
|
||||
|
||||
|
||||
@@ -414,13 +414,14 @@ fn finalization_step_label(view: &str, stage: &str) -> String {
|
||||
pub(crate) fn prepare_eligible_catalog(
|
||||
target: &ConfigTarget,
|
||||
playlist: Vec<PlaylistGroup>,
|
||||
group_uuids: &HashSet<UUIDType>,
|
||||
step: &mut StepMeasure,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let mut playlist = flatten_groups(playlist);
|
||||
step.tick("playlist merge");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
|
||||
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
|
||||
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist);
|
||||
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist, group_uuids);
|
||||
if removed > 0 {
|
||||
info!("Deduplicated {removed} eligible channels for target {}", target.name);
|
||||
}
|
||||
@@ -506,6 +507,28 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
|
||||
finalize_prepared_target_with_tmdb(ctx, prepared, tmdb_client.as_ref()).await
|
||||
}
|
||||
|
||||
async fn process_target_channel_groups(
|
||||
config: &tuliprox_core::model::AppConfig,
|
||||
target: &ConfigTarget,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
step: &mut StepMeasure,
|
||||
errors: &mut Vec<TuliproxError>,
|
||||
) -> Vec<PendingChannelGroup> {
|
||||
let Some(cg_cfg) = target.execution_plan.channel_groups.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let overrides =
|
||||
tuliprox_repository::load_channel_group_overrides(config, &target.name).await.unwrap_or_else(|err| {
|
||||
errors.push(err);
|
||||
None
|
||||
});
|
||||
step.broadcast("Processing channel groups for '{}' playlist", &target.name);
|
||||
let groups = crate::processor::channel_group::group_playlist(cg_cfg, overrides.as_ref(), &target.name, playlist);
|
||||
step.tick("channel grouping");
|
||||
log_memory_snapshot(format!("target '{}' after_channel_grouping", target.name).as_str());
|
||||
groups
|
||||
}
|
||||
|
||||
pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
|
||||
ctx: Arc<PlaylistProcessingContext<E, M>>,
|
||||
prepared: PreparedTarget,
|
||||
@@ -517,6 +540,10 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
|
||||
let mut errors = Vec::new();
|
||||
let broadcast_step = create_broadcast_callback(&ctx.events, &ctx.run_id, ctx.execution_order);
|
||||
let mut step = StepMeasure::new(&target.name, broadcast_step);
|
||||
|
||||
let pending_channel_groups =
|
||||
process_target_channel_groups(&ctx.config, target, &mut new_playlist, &mut step, &mut errors).await;
|
||||
|
||||
if target.favourites.is_some() {
|
||||
step.broadcast("Processing favourites for '{}' playlist", &target.name);
|
||||
process_favourites(&mut new_playlist, target.favourites.as_deref());
|
||||
@@ -531,8 +558,11 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
|
||||
return (Ok(()), errors);
|
||||
}
|
||||
|
||||
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
|
||||
let views = prepare_target_playlist_views(&ctx.client, tmdb_client, target, eligible_catalog).await;
|
||||
let group_uuids: HashSet<UUIDType> = pending_channel_groups.iter().map(|p| p.group_uuid).collect();
|
||||
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &group_uuids, &mut step);
|
||||
let mut views = prepare_target_playlist_views(&ctx.client, tmdb_client, target, eligible_catalog).await;
|
||||
views.channel_groups = pending_channel_groups;
|
||||
|
||||
if views.base.is_empty()
|
||||
&& views.xtream.is_none()
|
||||
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
|
||||
@@ -577,7 +607,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
|
||||
flatten_tvguide(new_epg)
|
||||
};
|
||||
let library_empty = library_empty.for_filtered_playlist(&finalized_base);
|
||||
let result = persist_playlist_views(
|
||||
let result = persist_playlist_views_with_groups(
|
||||
&ctx.config,
|
||||
&mut finalized_base,
|
||||
finalized_xtream.as_deref_mut(),
|
||||
@@ -589,6 +619,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
|
||||
library_empty,
|
||||
publication_plan: views.publication_plan,
|
||||
},
|
||||
&mut views.channel_groups,
|
||||
)
|
||||
.await;
|
||||
let watch_playlist = target_watch_view(&finalized_base, finalized_xtream.as_deref());
|
||||
@@ -901,6 +932,7 @@ pub(crate) struct TargetPlaylistViews {
|
||||
pub(crate) base: Vec<PlaylistGroup>,
|
||||
pub(crate) xtream: Option<Vec<PlaylistGroup>>,
|
||||
pub(crate) publication_plan: PlaylistPublicationPlan,
|
||||
pub(crate) channel_groups: Vec<PendingChannelGroup>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_curated_playlist_views(
|
||||
@@ -926,6 +958,7 @@ pub(crate) fn build_curated_playlist_views(
|
||||
has_xtream_output && !config.include_xtream_base_categories,
|
||||
appearance_filter_configured,
|
||||
),
|
||||
channel_groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +973,7 @@ pub(crate) async fn prepare_target_playlist_views(
|
||||
base: playlist,
|
||||
xtream: None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
channel_groups: Vec::new(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -954,13 +988,21 @@ pub(super) fn curation_playlist_views(
|
||||
outcome: CurationRunOutcome,
|
||||
) -> TargetPlaylistViews {
|
||||
match outcome {
|
||||
CurationRunOutcome::NotConfigured => {
|
||||
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
|
||||
}
|
||||
CurationRunOutcome::NotConfigured => TargetPlaylistViews {
|
||||
base: playlist,
|
||||
xtream: None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
channel_groups: Vec::new(),
|
||||
},
|
||||
CurationRunOutcome::Failed(failure) => {
|
||||
warn_curation_failure(&target.name, &failure);
|
||||
// Discovery is optional; unavailable selections must not suppress the source catalog or EPG.
|
||||
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
|
||||
TargetPlaylistViews {
|
||||
base: playlist,
|
||||
xtream: None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
channel_groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
CurationRunOutcome::Complete(evaluation) => {
|
||||
let views = build_curated_playlist_views(
|
||||
@@ -1170,3 +1212,23 @@ pub(crate) async fn process_watch<E: EventSink>(
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn run_finalize_until_persist(
|
||||
target: &ConfigTarget,
|
||||
mut playlist: Vec<PlaylistGroup>,
|
||||
) -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let mut step = StepMeasure::new("test", |_, _| {});
|
||||
let pending = if let Some(cg_cfg) = target.execution_plan.channel_groups.as_ref() {
|
||||
crate::processor::channel_group::group_playlist(cg_cfg, None, &target.name, &mut playlist)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if target.favourites.is_some() {
|
||||
process_favourites(&mut playlist, target.favourites.as_deref());
|
||||
}
|
||||
let group_uuids: HashSet<UUIDType> = pending.iter().map(|p| p.group_uuid).collect();
|
||||
let eligible = prepare_eligible_catalog(target, playlist, &group_uuids, &mut step);
|
||||
let finalized = finalize_playlist_view(target, eligible, &mut step, "base");
|
||||
(finalized, pending)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ mod curation_effect_gate;
|
||||
|
||||
mod watch_contexts;
|
||||
|
||||
mod channel_groups;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quality_rejection_fallback;
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::processor::playlist::target::run_finalize_until_persist;
|
||||
use shared::{
|
||||
model::{
|
||||
ConfigFavouritesDto, ConfigTargetDto, ConfigTargetOptions, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
|
||||
PlaylistItemType, Prepare, XtreamCluster,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use tuliprox_core::model::ConfigTarget;
|
||||
|
||||
fn live(input: &str, stream_id: &str, title: &str) -> PlaylistItem {
|
||||
let mut header = PlaylistItemHeader {
|
||||
input_name: input.intern(),
|
||||
id: stream_id.intern(),
|
||||
title: title.intern(),
|
||||
name: title.intern(),
|
||||
url: format!("http://{input}.invalid/live/u/p/{stream_id}.ts").intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..Default::default()
|
||||
};
|
||||
header.freeze_input_stream_id();
|
||||
header.gen_uuid();
|
||||
PlaylistItem { header }
|
||||
}
|
||||
|
||||
fn group(title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
|
||||
PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live }
|
||||
}
|
||||
|
||||
fn target_with_options(options_json: &str) -> ConfigTarget {
|
||||
let mut options: ConfigTargetOptions = serde_json::from_str(options_json).expect("valid options");
|
||||
options.prepare().expect("prepared");
|
||||
let dto = ConfigTargetDto { name: "T".to_string(), options: Some(options), ..Default::default() };
|
||||
ConfigTarget::from(&dto)
|
||||
}
|
||||
|
||||
fn target_with_options_and_favourites(options_json: &str, group_name: &str, filter_str: &str) -> ConfigTarget {
|
||||
let mut options: ConfigTargetOptions = serde_json::from_str(options_json).expect("valid options");
|
||||
options.prepare().expect("prepared");
|
||||
let mut fav = ConfigFavouritesDto {
|
||||
cluster: XtreamCluster::Live,
|
||||
group: group_name.intern(),
|
||||
filter: filter_str.to_string(),
|
||||
match_as_ascii: false,
|
||||
t_filter: None,
|
||||
};
|
||||
fav.prepare(None).expect("prepared filter");
|
||||
let dto = ConfigTargetDto {
|
||||
name: "T".to_string(),
|
||||
options: Some(options),
|
||||
favourites: Some(vec![fav]),
|
||||
..Default::default()
|
||||
};
|
||||
ConfigTarget::from(&dto)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favourites_copy_the_group_item_instead_of_joining_the_group() {
|
||||
let target = target_with_options_and_favourites(
|
||||
r#"{"channel_groups":{"enabled":true}}"#,
|
||||
"My Favourites",
|
||||
r#"Caption ~ "ZDF.*""#,
|
||||
);
|
||||
let playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])];
|
||||
let (finalized, pending) = run_finalize_until_persist(&target, playlist).await;
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].members.len(), 2);
|
||||
let fav = finalized.iter().find(|g| g.title.as_ref() == "My Favourites").expect("favourites group");
|
||||
assert_eq!(fav.channels.len(), 1);
|
||||
assert_eq!(fav.channels[0].header.channel_group_uuid, Some(pending[0].group_uuid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grouping_runs_before_deduplicate_and_dedup_ignores_group_members() {
|
||||
let target = target_with_options(r#"{"channel_groups":{"enabled":true},"deduplicate":{}}"#);
|
||||
let playlist = vec![group(
|
||||
"News",
|
||||
vec![
|
||||
live("solis", "1", "ZDF HD"),
|
||||
live("korath", "7", "ZDF FHD"),
|
||||
live("veyra", "3", "ARD HD"),
|
||||
live("veyra", "4", "ARD"),
|
||||
],
|
||||
)];
|
||||
let (finalized, pending) = run_finalize_until_persist(&target, playlist).await;
|
||||
assert_eq!(pending.len(), 2);
|
||||
assert_eq!(finalized[0].channels.len(), 2);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use tuliprox_core::{
|
||||
model::{ApiProxyConfig, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig},
|
||||
utils::FileLockManager,
|
||||
};
|
||||
use tuliprox_repository::persist_playlist_views;
|
||||
|
||||
pub(super) fn app_config(storage_dir: &Path) -> Arc<AppConfig> {
|
||||
Arc::new(AppConfig {
|
||||
|
||||
@@ -69,7 +69,7 @@ fn curation_eligible_catalog_is_merged_and_deduplicated_before_matching() {
|
||||
];
|
||||
let mut step = StepMeasure::new("test", |_, _| {});
|
||||
|
||||
let eligible = prepare_eligible_catalog(&target, playlist, &mut step);
|
||||
let eligible = prepare_eligible_catalog(&target, playlist, &std::collections::HashSet::new(), &mut step);
|
||||
|
||||
assert_eq!(eligible.len(), 1);
|
||||
assert_eq!(eligible[0].channels.len(), 1);
|
||||
|
||||
@@ -220,7 +220,7 @@ fn legacy_messagepack_playlist_items_default_missing_input_stream_id() {
|
||||
},
|
||||
};
|
||||
|
||||
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xa0]);
|
||||
let header_bytes = serialize_without_trailing_fields(&source.header, &[0xc0, 0xc0, 0xc0, 0xa0]);
|
||||
let decoded_header: PlaylistItemHeader =
|
||||
rmp_serde::from_slice(&header_bytes).expect("legacy header should deserialize");
|
||||
assert!(decoded_header.input_stream_id.is_empty());
|
||||
@@ -259,8 +259,9 @@ fn previous_messagepack_playlist_items_default_missing_upstream_user_agent() {
|
||||
},
|
||||
};
|
||||
|
||||
let header: PlaylistItemHeader = rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0]))
|
||||
.expect("previous header should deserialize");
|
||||
let header: PlaylistItemHeader =
|
||||
rmp_serde::from_slice(&serialize_without_trailing_fields(&source.header, &[0xc0, 0xc0, 0xc0]))
|
||||
.expect("previous header should deserialize");
|
||||
let m3u: M3uPlaylistItem =
|
||||
rmp_serde::from_slice(&serialize_without_trailing_fields(&M3uPlaylistItem::from(&source), &[0xc0]))
|
||||
.expect("previous M3U item should deserialize");
|
||||
|
||||
@@ -289,11 +289,21 @@ async fn prepare_generic_stream_metadata<E: EventSink>(
|
||||
}
|
||||
|
||||
let (raw_video, raw_audio, stats) = match probe_data {
|
||||
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => (
|
||||
raw_video.map(|value| Arc::<str>::from(value.to_string())),
|
||||
raw_audio.map(|value| Arc::<str>::from(value.to_string())),
|
||||
stats,
|
||||
),
|
||||
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => {
|
||||
let rv = raw_video.map(|value| Arc::<str>::from(value.to_string()));
|
||||
let ra = raw_audio.map(|value| Arc::<str>::from(value.to_string()));
|
||||
if item_type.is_live() {
|
||||
let bitrate_kbps = stats.bitrate.map_or(0, |b| b / 1000);
|
||||
events.observe_stream_probe(
|
||||
Arc::clone(&input.name),
|
||||
Arc::from(unique_id),
|
||||
rv.as_deref(),
|
||||
ra.as_deref(),
|
||||
bitrate_kbps,
|
||||
);
|
||||
}
|
||||
(rv, ra, stats)
|
||||
}
|
||||
// The three failure arms are the only place the *reason* survives:
|
||||
// both outcome enums above collapse to a single `ProbeFailed`, so a
|
||||
// caller cannot tell a dead provider from a cancelled shutdown. The
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
use crate::{
|
||||
bplustree::{BPlusTree, BPlusTreeError, BPlusTreeQuery},
|
||||
storage_const::{FILE_CHANNEL_GROUPS, FILE_CHANNEL_GROUPS_ALIAS, FILE_CHANNEL_GROUPS_MEMBER},
|
||||
target_bouquet_repository::sanitize_target_prefix,
|
||||
};
|
||||
use log::debug;
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{
|
||||
ChannelGroupOverridesDto, ChannelGroupPolicy, ChannelMemberRefDto, PlaylistItem, PlaylistItemType,
|
||||
StreamProperties, UUIDType, VirtualId, XtreamCluster, CHANNEL_GROUP_OVERRIDES_VERSION,
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tuliprox_core::{
|
||||
model::{AppConfig, ConfigTarget},
|
||||
utils::{remove_file_if_exists, write_text_file_atomic},
|
||||
};
|
||||
|
||||
pub const CHANNEL_GROUP_MUTATION_LOCK: &str = "channel_group:mutation";
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ChannelGroupOrigin {
|
||||
Auto,
|
||||
Manual,
|
||||
AutoEdited,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingChannelGroup {
|
||||
pub group_uuid: UUIDType,
|
||||
pub key: Arc<str>,
|
||||
pub origin: ChannelGroupOrigin,
|
||||
pub policy: ChannelGroupPolicy,
|
||||
pub members: Vec<PlaylistItem>,
|
||||
pub orphaned: Vec<ChannelMemberRefDto>,
|
||||
pub epg_split: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MemberArchive {
|
||||
pub duration_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GroupMember {
|
||||
pub member_vid: VirtualId,
|
||||
pub input_name: Arc<str>,
|
||||
pub provider_id: u32,
|
||||
pub input_stream_id: Arc<str>,
|
||||
pub url: Arc<str>,
|
||||
pub item_type: PlaylistItemType,
|
||||
pub cluster: XtreamCluster,
|
||||
pub title: Arc<str>,
|
||||
pub archive: Option<MemberArchive>,
|
||||
pub epg_channel_id: Option<Arc<str>>,
|
||||
pub additional_properties: Option<StreamProperties>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ChannelGroupRecord {
|
||||
pub group_vid: VirtualId,
|
||||
pub key: Arc<str>,
|
||||
pub origin: ChannelGroupOrigin,
|
||||
pub policy: ChannelGroupPolicy,
|
||||
pub members: Vec<GroupMember>,
|
||||
pub orphaned: Vec<ChannelMemberRefDto>,
|
||||
}
|
||||
|
||||
pub struct ChannelGroupStagedPublication {
|
||||
target_path: PathBuf,
|
||||
staged_groups: Option<PathBuf>,
|
||||
staged_members: Option<PathBuf>,
|
||||
staged_aliases: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ChannelGroupStagedPublication {
|
||||
pub fn discard(self) { drop(self); }
|
||||
|
||||
pub fn publish(mut self) -> std::io::Result<()> {
|
||||
if let Some(p) = self.staged_groups.take() {
|
||||
std::fs::rename(&p, self.target_path.join(FILE_CHANNEL_GROUPS))?;
|
||||
} else {
|
||||
let _ = remove_file_if_exists(&self.target_path.join(FILE_CHANNEL_GROUPS));
|
||||
}
|
||||
|
||||
if let Some(p) = self.staged_members.take() {
|
||||
std::fs::rename(&p, self.target_path.join(FILE_CHANNEL_GROUPS_MEMBER))?;
|
||||
} else {
|
||||
let _ = remove_file_if_exists(&self.target_path.join(FILE_CHANNEL_GROUPS_MEMBER));
|
||||
}
|
||||
|
||||
if let Some(p) = self.staged_aliases.take() {
|
||||
std::fs::rename(&p, self.target_path.join(FILE_CHANNEL_GROUPS_ALIAS))?;
|
||||
} else {
|
||||
let _ = remove_file_if_exists(&self.target_path.join(FILE_CHANNEL_GROUPS_ALIAS));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChannelGroupStagedPublication {
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = &self.staged_groups {
|
||||
let _ = remove_file_if_exists(p);
|
||||
}
|
||||
if let Some(p) = &self.staged_members {
|
||||
let _ = remove_file_if_exists(p);
|
||||
}
|
||||
if let Some(p) = &self.staged_aliases {
|
||||
let _ = remove_file_if_exists(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChannelGroupStore;
|
||||
|
||||
impl ChannelGroupStore {
|
||||
pub fn write_staged(
|
||||
target_path: &Path,
|
||||
records: &[ChannelGroupRecord],
|
||||
aliases: &[(VirtualId, VirtualId)],
|
||||
) -> std::io::Result<ChannelGroupStagedPublication> {
|
||||
if records.is_empty() {
|
||||
return Ok(ChannelGroupStagedPublication {
|
||||
target_path: target_path.to_path_buf(),
|
||||
staged_groups: None,
|
||||
staged_members: None,
|
||||
staged_aliases: None,
|
||||
});
|
||||
}
|
||||
|
||||
let stage_suffix = format!(".stage-{}", uuid::Uuid::new_v4());
|
||||
let staged_groups_path = target_path.join(format!("{FILE_CHANNEL_GROUPS}{stage_suffix}"));
|
||||
let mut groups_tree = BPlusTree::<VirtualId, ChannelGroupRecord>::new();
|
||||
for record in records {
|
||||
groups_tree.insert(record.group_vid, record.clone());
|
||||
}
|
||||
if let Err(err) = groups_tree.store(&staged_groups_path) {
|
||||
let _ = remove_file_if_exists(&staged_groups_path);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let staged_members_path = target_path.join(format!("{FILE_CHANNEL_GROUPS_MEMBER}{stage_suffix}"));
|
||||
let mut members_tree = BPlusTree::<VirtualId, VirtualId>::new();
|
||||
for record in records {
|
||||
for member in &record.members {
|
||||
members_tree.insert(member.member_vid, record.group_vid);
|
||||
}
|
||||
}
|
||||
if let Err(err) = members_tree.store(&staged_members_path) {
|
||||
let _ = remove_file_if_exists(&staged_groups_path);
|
||||
let _ = remove_file_if_exists(&staged_members_path);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let staged_aliases_path = if aliases.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let path = target_path.join(format!("{FILE_CHANNEL_GROUPS_ALIAS}{stage_suffix}"));
|
||||
let mut aliases_tree = BPlusTree::<VirtualId, VirtualId>::new();
|
||||
for (alias_vid, group_vid) in aliases {
|
||||
aliases_tree.insert(*alias_vid, *group_vid);
|
||||
}
|
||||
if let Err(err) = aliases_tree.store(&path) {
|
||||
let _ = remove_file_if_exists(&staged_groups_path);
|
||||
let _ = remove_file_if_exists(&staged_members_path);
|
||||
let _ = remove_file_if_exists(&path);
|
||||
return Err(err);
|
||||
}
|
||||
Some(path)
|
||||
};
|
||||
|
||||
Ok(ChannelGroupStagedPublication {
|
||||
target_path: target_path.to_path_buf(),
|
||||
staged_groups: Some(staged_groups_path),
|
||||
staged_members: Some(staged_members_path),
|
||||
staged_aliases: staged_aliases_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write(
|
||||
target_path: &Path,
|
||||
records: &[ChannelGroupRecord],
|
||||
aliases: &[(VirtualId, VirtualId)],
|
||||
) -> std::io::Result<()> {
|
||||
Self::write_staged(target_path, records, aliases)?.publish()
|
||||
}
|
||||
|
||||
pub fn delete(target_path: &Path) -> std::io::Result<()> {
|
||||
let _ = remove_file_if_exists(&target_path.join(FILE_CHANNEL_GROUPS));
|
||||
let _ = remove_file_if_exists(&target_path.join(FILE_CHANNEL_GROUPS_MEMBER));
|
||||
let _ = remove_file_if_exists(&target_path.join(FILE_CHANNEL_GROUPS_ALIAS));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_alias(target_path: &Path, vid: VirtualId) -> std::io::Result<Option<VirtualId>> {
|
||||
let db_path = target_path.join(FILE_CHANNEL_GROUPS_ALIAS);
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut query = match BPlusTreeQuery::<VirtualId, VirtualId>::try_new(&db_path) {
|
||||
Ok(q) => q,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
query.query(&vid).map_err(BPlusTreeError::to_io)
|
||||
}
|
||||
|
||||
pub fn read_group(target_path: &Path, group_vid: VirtualId) -> std::io::Result<Option<ChannelGroupRecord>> {
|
||||
let db_path = target_path.join(FILE_CHANNEL_GROUPS);
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut query = match BPlusTreeQuery::<VirtualId, ChannelGroupRecord>::try_new(&db_path) {
|
||||
Ok(q) => q,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
query.query(&group_vid).map_err(BPlusTreeError::to_io)
|
||||
}
|
||||
|
||||
pub fn read_member(
|
||||
target_path: &Path,
|
||||
member_vid: VirtualId,
|
||||
) -> std::io::Result<Option<(ChannelGroupRecord, usize)>> {
|
||||
let db_path = target_path.join(FILE_CHANNEL_GROUPS_MEMBER);
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut member_query = match BPlusTreeQuery::<VirtualId, VirtualId>::try_new(&db_path) {
|
||||
Ok(q) => q,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(group_vid) = member_query.query(&member_vid).map_err(BPlusTreeError::to_io)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
drop(member_query);
|
||||
let Some(record) = Self::read_group(target_path, group_vid)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(idx) = record.members.iter().position(|m| m.member_vid == member_vid) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some((record, idx)))
|
||||
}
|
||||
|
||||
pub fn read_all_groups(target_path: &Path) -> std::io::Result<Vec<ChannelGroupRecord>> {
|
||||
let db_path = target_path.join(FILE_CHANNEL_GROUPS);
|
||||
if !db_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut query = match BPlusTreeQuery::<VirtualId, ChannelGroupRecord>::try_new(&db_path) {
|
||||
Ok(q) => q,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
for entry in query.iter() {
|
||||
let (_key, record) = entry?;
|
||||
records.push(record);
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn read_all_aliases(target_path: &Path) -> std::io::Result<HashMap<VirtualId, VirtualId>> {
|
||||
let db_path = target_path.join(FILE_CHANNEL_GROUPS_ALIAS);
|
||||
if !db_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let mut query = match BPlusTreeQuery::<VirtualId, VirtualId>::try_new(&db_path) {
|
||||
Ok(q) => q,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut aliases = HashMap::new();
|
||||
for entry in query.iter() {
|
||||
let (alias_vid, group_vid) = entry?;
|
||||
aliases.insert(alias_vid, group_vid);
|
||||
}
|
||||
Ok(aliases)
|
||||
}
|
||||
|
||||
pub fn load_target_data(target_path: &Path, generation: u64) -> std::io::Result<ChannelGroupTargetData> {
|
||||
let records = Self::read_all_groups(target_path)?;
|
||||
let aliases = Self::read_all_aliases(target_path)?;
|
||||
let mut groups = HashMap::with_capacity(records.len());
|
||||
let mut members = HashMap::new();
|
||||
for record in records {
|
||||
let group_vid = record.group_vid;
|
||||
for (idx, member) in record.members.iter().enumerate() {
|
||||
members.insert(member.member_vid, (group_vid, idx));
|
||||
}
|
||||
groups.insert(group_vid, Arc::new(record));
|
||||
}
|
||||
Ok(ChannelGroupTargetData { generation, groups, members, aliases })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChannelGroupTargetData {
|
||||
pub generation: u64,
|
||||
pub groups: HashMap<VirtualId, Arc<ChannelGroupRecord>>,
|
||||
pub members: HashMap<VirtualId, (VirtualId, usize)>,
|
||||
pub aliases: HashMap<VirtualId, VirtualId>,
|
||||
}
|
||||
|
||||
pub struct ChannelGroupCache {
|
||||
targets: tokio::sync::RwLock<HashMap<String, Arc<ChannelGroupTargetData>>>,
|
||||
}
|
||||
|
||||
impl Default for ChannelGroupCache {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl ChannelGroupCache {
|
||||
#[must_use]
|
||||
pub fn new() -> Self { Self { targets: tokio::sync::RwLock::new(HashMap::new()) } }
|
||||
|
||||
pub async fn get_or_load(
|
||||
&self,
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &crate::PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
) -> Arc<ChannelGroupTargetData> {
|
||||
let current_generation = playlists.channel_group_generation();
|
||||
{
|
||||
let guard = self.targets.read().await;
|
||||
if let Some(cached) = guard.get(&target.name) {
|
||||
if cached.generation == current_generation {
|
||||
return Arc::clone(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config = app_config.config.load();
|
||||
let Some(target_path) = crate::get_target_storage_path(&config, &target.name) else {
|
||||
return Arc::new(ChannelGroupTargetData {
|
||||
generation: current_generation,
|
||||
groups: HashMap::new(),
|
||||
members: HashMap::new(),
|
||||
aliases: HashMap::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let file_lock = app_config.file_locks.read_lock(&target_path).await;
|
||||
let loaded = tokio::task::spawn_blocking(move || {
|
||||
let _guard = file_lock;
|
||||
ChannelGroupStore::load_target_data(&target_path, current_generation)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Ok(ChannelGroupTargetData {
|
||||
generation: current_generation,
|
||||
groups: HashMap::new(),
|
||||
members: HashMap::new(),
|
||||
aliases: HashMap::new(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|_| ChannelGroupTargetData {
|
||||
generation: current_generation,
|
||||
groups: HashMap::new(),
|
||||
members: HashMap::new(),
|
||||
aliases: HashMap::new(),
|
||||
});
|
||||
|
||||
let arc_data = Arc::new(loaded);
|
||||
let mut guard = self.targets.write().await;
|
||||
guard.insert(target.name.clone(), Arc::clone(&arc_data));
|
||||
arc_data
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChannelGroupSaveError {
|
||||
RevisionMismatch { current: u64 },
|
||||
Io(TuliproxError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ChannelGroupSaveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RevisionMismatch { current } => write!(f, "revision mismatch: current is {current}"),
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChannelGroupSaveError {}
|
||||
|
||||
impl From<TuliproxError> for ChannelGroupSaveError {
|
||||
fn from(err: TuliproxError) -> Self { Self::Io(err) }
|
||||
}
|
||||
|
||||
/// Returns the channel group storage directory derived from `config_path`.
|
||||
pub fn channel_group_overrides_dir(config_path: &Path) -> PathBuf { config_path.join("channel_groups") }
|
||||
|
||||
/// Derives the canonical filename for a target's channel group overrides.
|
||||
pub fn channel_group_overrides_file_name(target_name: &str) -> String {
|
||||
let prefix = sanitize_target_prefix(target_name);
|
||||
let hash = blake3::hash(target_name.as_bytes());
|
||||
let hex = hash.to_hex();
|
||||
format!("{}--{}.yml", prefix, &hex[..12])
|
||||
}
|
||||
|
||||
/// Derives the full path to a target's channel group overrides file.
|
||||
pub fn channel_group_overrides_path(config_path: &Path, target_name: &str) -> PathBuf {
|
||||
channel_group_overrides_dir(config_path).join(channel_group_overrides_file_name(target_name))
|
||||
}
|
||||
|
||||
/// Loads a target's channel group overrides from disk. Returns `Ok(None)` if the file does not exist.
|
||||
/// Fails closed with an error on corruption, target mismatch, or unsupported version.
|
||||
pub async fn load_channel_group_overrides(
|
||||
app_config: &AppConfig,
|
||||
target_name: &str,
|
||||
) -> Result<Option<ChannelGroupOverridesDto>, TuliproxError> {
|
||||
let paths = app_config.paths.load();
|
||||
let config_path = Path::new(&paths.config_path);
|
||||
let file_path = channel_group_overrides_path(config_path, target_name);
|
||||
|
||||
let _lock = app_config.file_locks.read_lock(&file_path).await;
|
||||
|
||||
let content = match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(c) => c,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(TuliproxError::ChannelGroup(format!(
|
||||
"Failed to read channel group overrides file {}: {err}",
|
||||
file_path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut dto: ChannelGroupOverridesDto = serde_saphyr::from_str(&content).map_err(|err| {
|
||||
TuliproxError::ChannelGroup(format!(
|
||||
"Failed to parse channel group overrides YAML in {}: {err}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if dto.version != CHANNEL_GROUP_OVERRIDES_VERSION {
|
||||
return Err(TuliproxError::ChannelGroup(format!(
|
||||
"Unsupported channel group overrides version {} in {}",
|
||||
dto.version,
|
||||
file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if dto.target != target_name {
|
||||
return Err(TuliproxError::ChannelGroup(format!(
|
||||
"Target name mismatch in {}: file contains target '{}', expected '{target_name}'",
|
||||
file_path.display(),
|
||||
dto.target
|
||||
)));
|
||||
}
|
||||
|
||||
dto.canonicalize();
|
||||
Ok(Some(dto))
|
||||
}
|
||||
|
||||
/// Saves channel group overrides with optimistic concurrency control.
|
||||
/// Returns the new revision on success.
|
||||
pub async fn save_channel_group_overrides(
|
||||
app_config: &AppConfig,
|
||||
target_name: &str,
|
||||
mut dto: ChannelGroupOverridesDto,
|
||||
expected_revision: u64,
|
||||
) -> Result<u64, ChannelGroupSaveError> {
|
||||
let paths = app_config.paths.load();
|
||||
let config_path = Path::new(&paths.config_path);
|
||||
let file_path = channel_group_overrides_path(config_path, target_name);
|
||||
|
||||
let _mutation_lock = app_config.file_locks.write_lock_str(CHANNEL_GROUP_MUTATION_LOCK).await;
|
||||
let _file_lock = app_config.file_locks.write_lock(&file_path).await;
|
||||
|
||||
let current = match tokio::fs::read_to_string(&file_path).await {
|
||||
Ok(content) => {
|
||||
let existing: ChannelGroupOverridesDto = serde_saphyr::from_str(&content).map_err(|err| {
|
||||
TuliproxError::ChannelGroup(format!(
|
||||
"Failed to parse channel group overrides YAML in {}: {err}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
if existing.target != target_name {
|
||||
return Err(ChannelGroupSaveError::Io(TuliproxError::ChannelGroup(format!(
|
||||
"Target mismatch in existing overrides file {}: '{}' != '{target_name}'",
|
||||
file_path.display(),
|
||||
existing.target
|
||||
))));
|
||||
}
|
||||
existing.revision
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
|
||||
Err(err) => {
|
||||
return Err(ChannelGroupSaveError::Io(TuliproxError::ChannelGroup(format!(
|
||||
"Failed to read channel group overrides {}: {err}",
|
||||
file_path.display()
|
||||
))));
|
||||
}
|
||||
};
|
||||
|
||||
if current != expected_revision {
|
||||
return Err(ChannelGroupSaveError::RevisionMismatch { current });
|
||||
}
|
||||
|
||||
let next_revision = current + 1;
|
||||
dto.version = CHANNEL_GROUP_OVERRIDES_VERSION;
|
||||
dto.target = target_name.to_string();
|
||||
dto.revision = next_revision;
|
||||
dto.canonicalize();
|
||||
|
||||
let yaml = serde_saphyr::to_string(&dto).map_err(|err| {
|
||||
TuliproxError::ChannelGroup(format!("Failed to serialize channel group overrides to YAML: {err}"))
|
||||
})?;
|
||||
|
||||
write_text_file_atomic(&file_path, &yaml).await.map_err(|err| {
|
||||
TuliproxError::ChannelGroup(format!("Failed to write channel group overrides {}: {err}", file_path.display()))
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
"Saved channel group overrides for '{}' (revision {}) to {}",
|
||||
target_name,
|
||||
next_revision,
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
Ok(next_revision)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::model::ConfigPaths;
|
||||
use std::sync::Arc;
|
||||
use tuliprox_core::{
|
||||
model::{Config, MediaToolCapabilities, SourcesConfig},
|
||||
utils::FileLockManager,
|
||||
};
|
||||
|
||||
fn test_app_config(config_path: &str) -> AppConfig {
|
||||
AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config::default())),
|
||||
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
|
||||
hdhomerun: Arc::new(ArcSwapOption::default()),
|
||||
api_proxy: Arc::new(ArcSwapOption::default()),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: config_path.to_string(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::default()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(MediaToolCapabilities::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_app_config_with_config_dir() -> (AppConfig, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cfg = test_app_config(dir.path().to_str().expect("utf-8 path"));
|
||||
(cfg, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_load_round_trips_and_bumps_revision() {
|
||||
let (app_config, _dir) = test_app_config_with_config_dir();
|
||||
let dto = ChannelGroupOverridesDto::new("Main TV");
|
||||
let revision = save_channel_group_overrides(&app_config, "Main TV", dto, 0).await.expect("saves");
|
||||
assert_eq!(revision, 1);
|
||||
let loaded = load_channel_group_overrides(&app_config, "Main TV").await.expect("loads").expect("exists");
|
||||
assert_eq!(loaded.revision, 1);
|
||||
assert_eq!(loaded.target, "Main TV");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_revision_is_rejected() {
|
||||
let (app_config, _dir) = test_app_config_with_config_dir();
|
||||
save_channel_group_overrides(&app_config, "T", ChannelGroupOverridesDto::new("T"), 0).await.expect("saves");
|
||||
let err =
|
||||
save_channel_group_overrides(&app_config, "T", ChannelGroupOverridesDto::new("T"), 0).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelGroupSaveError::RevisionMismatch { current: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_mismatch_and_unknown_version_fail_closed() {
|
||||
let (app_config, _dir) = test_app_config_with_config_dir();
|
||||
let path = channel_group_overrides_path(Path::new(&app_config.paths.load().config_path), "T");
|
||||
tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
||||
tokio::fs::write(&path, "version: 1\ntarget: Other\nrevision: 3\n").await.unwrap();
|
||||
assert!(load_channel_group_overrides(&app_config, "T").await.is_err());
|
||||
tokio::fs::write(&path, "version: 9\ntarget: T\nrevision: 3\n").await.unwrap();
|
||||
assert!(load_channel_group_overrides(&app_config, "T").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_file_is_none() {
|
||||
let (app_config, _dir) = test_app_config_with_config_dir();
|
||||
assert!(load_channel_group_overrides(&app_config, "T").await.expect("ok").is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod target_id_mapping;
|
||||
// `utils`, which must not depend on this layer.
|
||||
mod alias_repository;
|
||||
mod api_user_recovery;
|
||||
pub mod channel_group_repository;
|
||||
mod db_viewer;
|
||||
mod epg_group_index;
|
||||
mod epg_repository;
|
||||
@@ -42,6 +43,7 @@ mod xtream_playlist_iterator;
|
||||
mod xtream_repository;
|
||||
|
||||
pub use alias_repository::*;
|
||||
pub use channel_group_repository::*;
|
||||
pub use db_viewer::*;
|
||||
pub use epg_group_index::*;
|
||||
pub use epg_repository::*;
|
||||
@@ -74,6 +76,8 @@ pub use stream_history::*;
|
||||
pub use strm_repository::*;
|
||||
pub use target_bouquet_repository::*;
|
||||
pub use target_id_mapping::*;
|
||||
pub mod target_stream_item;
|
||||
pub use target_stream_item::*;
|
||||
// The B+Tree storage engine is its own package. Aliased under its historical
|
||||
// module name so every `crate::bplustree::X` path keeps resolving.
|
||||
pub use tuliprox_btree as bplustree;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::{BPlusTree, VirtualIdRecord};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistItem, VirtualId, XtreamCluster, XtreamPlaylistItem};
|
||||
use std::collections::HashMap;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tuliprox_core::model::ConfigTarget;
|
||||
|
||||
@@ -27,6 +30,8 @@ pub type TargetPlaylistStorageMap = HashMap<String, TargetPlaylistStorage>;
|
||||
|
||||
pub struct PlaylistStorageState {
|
||||
pub data: RwLock<TargetPlaylistStorageMap>,
|
||||
pub channel_group_generation: AtomicU64,
|
||||
pub channel_groups: crate::ChannelGroupCache,
|
||||
}
|
||||
|
||||
impl Default for PlaylistStorageState {
|
||||
@@ -34,7 +39,20 @@ impl Default for PlaylistStorageState {
|
||||
}
|
||||
|
||||
impl PlaylistStorageState {
|
||||
pub fn new() -> Self { Self { data: RwLock::new(HashMap::new()) } }
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data: RwLock::new(HashMap::new()),
|
||||
channel_group_generation: AtomicU64::new(0),
|
||||
channel_groups: crate::ChannelGroupCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bump_channel_group_generation(&self) -> u64 {
|
||||
self.channel_group_generation.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn channel_group_generation(&self) -> u64 { self.channel_group_generation.load(Ordering::SeqCst) }
|
||||
|
||||
/// Replaces one target only after its complete persisted state has been loaded.
|
||||
pub(crate) async fn replace_target(&self, target_name: &str, storage: TargetPlaylistStorage) {
|
||||
|
||||
@@ -37,6 +37,7 @@ use persist::persist_playlist_with_mode;
|
||||
#[allow(unused_imports, reason = "Retains the existing module interface in production and test builds.")]
|
||||
pub use persist::{
|
||||
load_input_media_server_playlist, persist_input_media_server_playlist, persist_playlist, persist_playlist_views,
|
||||
persist_playlist_views_with_groups,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use publication::TargetCacheReloadStage;
|
||||
|
||||
@@ -37,41 +37,47 @@ pub(super) fn normalize_target_playlist_epg_ids(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn assign_item_identity(
|
||||
header: &mut PlaylistItemHeader,
|
||||
source_ordinal: &mut u32,
|
||||
target_id_mapping: &mut TargetIdMapping,
|
||||
) {
|
||||
*source_ordinal += 1;
|
||||
header.source_ordinal = *source_ordinal;
|
||||
let provider_id = header.get_provider_id().unwrap_or_default();
|
||||
if provider_id == 0 {
|
||||
header.item_type = match (is_hls_url(&header.url), header.item_type) {
|
||||
(true, _) => PlaylistItemType::LiveHls,
|
||||
(false, PlaylistItemType::Live) => {
|
||||
if is_dash_url(&header.url) {
|
||||
PlaylistItemType::LiveDash
|
||||
} else {
|
||||
PlaylistItemType::LiveUnknown
|
||||
}
|
||||
}
|
||||
_ => header.item_type,
|
||||
};
|
||||
}
|
||||
|
||||
let uuid = header.get_uuid();
|
||||
let item_type = header.item_type;
|
||||
let parent_virtual_id = if item_type.is_series() {
|
||||
target_id_mapping.get_parent_virtual_id_by_uuid(uuid).unwrap_or_default()
|
||||
} else {
|
||||
VirtualId::default()
|
||||
};
|
||||
header.virtual_id = target_id_mapping.get_and_update_virtual_id(uuid, provider_id, item_type, parent_virtual_id);
|
||||
}
|
||||
|
||||
pub(super) fn prepare_target_playlist_for_persistence(
|
||||
playlist: &mut [PlaylistGroup],
|
||||
target: &ConfigTarget,
|
||||
target_id_mapping: &mut TargetIdMapping,
|
||||
source_ordinal: &mut u32,
|
||||
) {
|
||||
let mut source_ordinal: u32 = 0;
|
||||
for group in playlist.iter_mut() {
|
||||
for channel in &mut group.channels {
|
||||
let header = &mut channel.header;
|
||||
source_ordinal += 1;
|
||||
header.source_ordinal = source_ordinal;
|
||||
let provider_id = header.get_provider_id().unwrap_or_default();
|
||||
if provider_id == 0 {
|
||||
header.item_type = match (is_hls_url(&header.url), header.item_type) {
|
||||
(true, _) => PlaylistItemType::LiveHls,
|
||||
(false, PlaylistItemType::Live) => {
|
||||
if is_dash_url(&header.url) {
|
||||
PlaylistItemType::LiveDash
|
||||
} else {
|
||||
PlaylistItemType::LiveUnknown
|
||||
}
|
||||
}
|
||||
_ => header.item_type,
|
||||
};
|
||||
}
|
||||
|
||||
let uuid = header.get_uuid();
|
||||
let item_type = header.item_type;
|
||||
let parent_virtual_id = if item_type.is_series() {
|
||||
target_id_mapping.get_parent_virtual_id_by_uuid(uuid).unwrap_or_default()
|
||||
} else {
|
||||
VirtualId::default()
|
||||
};
|
||||
header.virtual_id =
|
||||
target_id_mapping.get_and_update_virtual_id(uuid, provider_id, item_type, parent_virtual_id);
|
||||
assign_item_identity(&mut channel.header, source_ordinal, target_id_mapping);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
episode::prepare_target_playlist_for_persistence,
|
||||
episode::{assign_item_identity, prepare_target_playlist_for_persistence},
|
||||
get_target_id_mapping,
|
||||
publication::{
|
||||
persist_xtream_target_playlist, playlist_has_items, prepare_target_output_playlists,
|
||||
@@ -10,9 +10,13 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
ensure_target_storage_path, epg_write_for_target, load_input_local_library_playlist, m3u_write_playlist,
|
||||
persist_input_library_playlist, write_strm_playlist,
|
||||
persist_input_library_playlist, write_strm_playlist, ChannelGroupRecord, ChannelGroupStore, GroupMember,
|
||||
MemberArchive, PendingChannelGroup, TargetIdMapping,
|
||||
};
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{PlaylistGroup, StreamProperties, VirtualId},
|
||||
};
|
||||
use shared::{error::TuliproxError, model::PlaylistGroup};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tuliprox_core::model::{AppConfig, ConfigTarget, Epg, TargetOutput};
|
||||
|
||||
@@ -44,6 +48,30 @@ pub async fn persist_playlist_views(
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_views_with_groups(
|
||||
app_config,
|
||||
base_playlist,
|
||||
xtream_playlist,
|
||||
epg,
|
||||
target,
|
||||
playlist_state,
|
||||
options,
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn persist_playlist_views_with_groups(
|
||||
app_config: &Arc<AppConfig>,
|
||||
base_playlist: &mut [PlaylistGroup],
|
||||
xtream_playlist: Option<&mut [PlaylistGroup]>,
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
channel_groups: &mut [PendingChannelGroup],
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_views_with_mode(
|
||||
app_config,
|
||||
@@ -54,6 +82,7 @@ pub async fn persist_playlist_views(
|
||||
playlist_state,
|
||||
options,
|
||||
TargetPersistenceMode::Persist,
|
||||
channel_groups,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -67,8 +96,83 @@ pub(super) async fn persist_playlist_with_mode(
|
||||
options: TargetPlaylistPersistOptions,
|
||||
persistence_mode: TargetPersistenceMode,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_views_with_mode(app_config, playlist, None, epg, target, playlist_state, options, persistence_mode)
|
||||
.await
|
||||
persist_playlist_views_with_mode(
|
||||
app_config,
|
||||
playlist,
|
||||
None,
|
||||
epg,
|
||||
target,
|
||||
playlist_state,
|
||||
options,
|
||||
persistence_mode,
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn collect_channel_group_aliases(
|
||||
playlist: &[PlaylistGroup],
|
||||
mapping: &TargetIdMapping,
|
||||
aliases: &mut Vec<(VirtualId, VirtualId)>,
|
||||
) {
|
||||
for group in playlist {
|
||||
for channel in &group.channels {
|
||||
if let Some(cg_uuid) = channel.header.channel_group_uuid {
|
||||
if channel.header.uuid != cg_uuid {
|
||||
if let Some(group_vid) = mapping.get_virtual_id_by_uuid(&cg_uuid) {
|
||||
aliases.push((channel.header.virtual_id, group_vid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_channel_group_records(
|
||||
channel_groups: &[PendingChannelGroup],
|
||||
mapping: &TargetIdMapping,
|
||||
) -> Vec<ChannelGroupRecord> {
|
||||
channel_groups
|
||||
.iter()
|
||||
.map(|pending| {
|
||||
let group_vid = mapping.get_virtual_id_by_uuid(&pending.group_uuid).unwrap_or_default();
|
||||
let members = pending
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| {
|
||||
let archive = if let Some(StreamProperties::Live(live)) = &member.header.additional_properties {
|
||||
live.tv_archive_duration
|
||||
.and_then(|d| u32::try_from(d).ok())
|
||||
.map(|duration_days| MemberArchive { duration_days })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
GroupMember {
|
||||
member_vid: member.header.virtual_id,
|
||||
input_name: Arc::clone(&member.header.input_name),
|
||||
provider_id: shared::utils::get_provider_id(&member.header.id, &member.header.url)
|
||||
.unwrap_or_default(),
|
||||
input_stream_id: Arc::clone(&member.header.input_stream_id),
|
||||
url: Arc::clone(&member.header.url),
|
||||
item_type: member.header.item_type,
|
||||
cluster: member.header.xtream_cluster,
|
||||
title: Arc::clone(&member.header.title),
|
||||
archive,
|
||||
epg_channel_id: member.header.epg_channel_id.as_ref().map(Arc::clone),
|
||||
additional_properties: member.header.additional_properties.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ChannelGroupRecord {
|
||||
group_vid,
|
||||
key: Arc::clone(&pending.key),
|
||||
origin: pending.origin,
|
||||
policy: pending.policy,
|
||||
members,
|
||||
orphaned: pending.orphaned.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
@@ -81,6 +185,7 @@ async fn persist_playlist_views_with_mode(
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
persistence_mode: TargetPersistenceMode,
|
||||
channel_groups: &mut [PendingChannelGroup],
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
let playlist_is_empty = !playlist_has_items(base_playlist)
|
||||
&& xtream_playlist.as_deref().is_none_or(|playlist| !playlist_has_items(playlist));
|
||||
@@ -100,9 +205,16 @@ async fn persist_playlist_views_with_mode(
|
||||
Err(err) => return Err(vec![err]),
|
||||
};
|
||||
|
||||
prepare_target_playlist_for_persistence(base_playlist, target, &mut target_id_mapping);
|
||||
let mut source_ordinal = 0u32;
|
||||
prepare_target_playlist_for_persistence(base_playlist, target, &mut target_id_mapping, &mut source_ordinal);
|
||||
if let Some(xtream_view) = xtream_playlist.as_deref_mut() {
|
||||
prepare_target_playlist_for_persistence(xtream_view, target, &mut target_id_mapping);
|
||||
prepare_target_playlist_for_persistence(xtream_view, target, &mut target_id_mapping, &mut source_ordinal);
|
||||
}
|
||||
|
||||
for pending in channel_groups.iter_mut() {
|
||||
for member in &mut pending.members {
|
||||
assign_item_identity(&mut member.header, &mut source_ordinal, &mut target_id_mapping);
|
||||
}
|
||||
}
|
||||
|
||||
let mut prepared_outputs = prepare_target_output_playlists(target, base_playlist, xtream_playlist.as_deref());
|
||||
@@ -113,6 +225,26 @@ async fn persist_playlist_views_with_mode(
|
||||
return Err(vec![error]);
|
||||
}
|
||||
|
||||
let records = build_channel_group_records(channel_groups, &target_id_mapping);
|
||||
let mut aliases = Vec::new();
|
||||
collect_channel_group_aliases(base_playlist, &target_id_mapping, &mut aliases);
|
||||
if let Some(xtream_view) = xtream_playlist.as_deref() {
|
||||
collect_channel_group_aliases(xtream_view, &target_id_mapping, &mut aliases);
|
||||
}
|
||||
|
||||
let staged_groups = match ChannelGroupStore::write_staged(&target_path, &records, &aliases) {
|
||||
Ok(staged) => staged,
|
||||
Err(err) => {
|
||||
target_id_mapping.discard_unpersisted_changes();
|
||||
drop(target_id_mapping);
|
||||
drop(file_lock);
|
||||
return Err(vec![TuliproxError::RepositoryPlaylist(format!(
|
||||
"Failed to stage channel groups for target '{}': {err}",
|
||||
target.name
|
||||
))]);
|
||||
}
|
||||
};
|
||||
|
||||
for (output, prepared_output) in target.output.iter().zip(&mut prepared_outputs) {
|
||||
let output_publication_plan = options.publication_plan.with_output_filter(output.filter().is_some());
|
||||
let source_playlist = match output {
|
||||
@@ -181,6 +313,19 @@ async fn persist_playlist_views_with_mode(
|
||||
drop(target_id_mapping);
|
||||
drop(file_lock);
|
||||
|
||||
if errors.is_empty() {
|
||||
if let Err(err) = staged_groups.publish() {
|
||||
errors.push(TuliproxError::RepositoryPlaylist(format!(
|
||||
"Failed to publish channel groups for target '{}': {err}",
|
||||
target.name
|
||||
)));
|
||||
} else if let Some(playlist_storage) = playlist_state {
|
||||
playlist_storage.bump_channel_group_generation();
|
||||
}
|
||||
} else {
|
||||
staged_groups.discard();
|
||||
}
|
||||
|
||||
if errors.is_empty() && target.use_memory_cache {
|
||||
match playlist_state {
|
||||
Some(playlist_storage) => {
|
||||
|
||||
@@ -388,6 +388,7 @@ fn make_media_server_episode(
|
||||
}
|
||||
}
|
||||
|
||||
mod channel_groups;
|
||||
mod lifecycle;
|
||||
mod playlist;
|
||||
mod policy;
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
use super::*;
|
||||
use crate::{persist_playlist_views_with_groups, ChannelGroupOrigin, ChannelGroupStore, PendingChannelGroup};
|
||||
use shared::model::ChannelGroupPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn persist_test_env(_config_json: &str) -> (Arc<AppConfig>, ConfigTarget, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let app_config = test_app_config(dir.path());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "target-groups-test".to_string(),
|
||||
output: vec![TargetOutputDto::M3u(M3uTargetOutputDto {
|
||||
filename: Some("playlist.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
(app_config, target, dir)
|
||||
}
|
||||
|
||||
fn persist_test_env_rejecting_empty() -> (Arc<AppConfig>, ConfigTarget, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let app_config = test_app_config(dir.path());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "target-reject-test".to_string(),
|
||||
output: vec![TargetOutputDto::M3u(M3uTargetOutputDto {
|
||||
filename: Some("playlist.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
(app_config, target, dir)
|
||||
}
|
||||
|
||||
fn target_path(app_config: &AppConfig, target: &ConfigTarget) -> PathBuf {
|
||||
let config = app_config.config.load();
|
||||
get_target_storage_path(&config, &target.name).expect("target storage path")
|
||||
}
|
||||
|
||||
fn grouped_fixture() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let group_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
|
||||
let group_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: group_uuid,
|
||||
id: "bbc-one".intern(),
|
||||
input_stream_id: "bbc-one".intern(),
|
||||
name: "BBC One".intern(),
|
||||
title: "BBC One".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://korath/live/101".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
channel_group_uuid: Some(group_uuid),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let normal_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002"),
|
||||
id: "cnn".intern(),
|
||||
input_stream_id: "cnn".intern(),
|
||||
name: "CNN".intern(),
|
||||
title: "CNN".intern(),
|
||||
group: "News".intern(),
|
||||
url: "http://korath/live/cnn".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let member1 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011"),
|
||||
id: "101".intern(),
|
||||
input_stream_id: "101".intern(),
|
||||
name: "BBC One FHD".intern(),
|
||||
title: "BBC One FHD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://korath/live/101".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let member2 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012"),
|
||||
id: "201".intern(),
|
||||
input_stream_id: "201".intern(),
|
||||
name: "BBC One HD".intern(),
|
||||
title: "BBC One HD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://xandar/live/201".intern(),
|
||||
input_name: "xandar".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Entertainment".intern(),
|
||||
channels: vec![group_channel],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "News".intern(),
|
||||
channels: vec![normal_channel],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
];
|
||||
let pending = vec![PendingChannelGroup {
|
||||
group_uuid,
|
||||
key: Arc::from("bbc one"),
|
||||
origin: ChannelGroupOrigin::Auto,
|
||||
policy: ChannelGroupPolicy::Priority,
|
||||
members: vec![member1, member2],
|
||||
orphaned: vec![],
|
||||
epg_split: false,
|
||||
}];
|
||||
(playlist, pending)
|
||||
}
|
||||
|
||||
fn grouped_fixture_ungrouped() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let member1 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011"),
|
||||
id: "101".intern(),
|
||||
input_stream_id: "101".intern(),
|
||||
name: "BBC One FHD".intern(),
|
||||
title: "BBC One FHD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://korath/live/101".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let member2 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012"),
|
||||
id: "201".intern(),
|
||||
input_stream_id: "201".intern(),
|
||||
name: "BBC One HD".intern(),
|
||||
title: "BBC One HD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://xandar/live/201".intern(),
|
||||
input_name: "xandar".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Entertainment".intern(),
|
||||
channels: vec![member1, member2],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
}];
|
||||
(playlist, Vec::new())
|
||||
}
|
||||
|
||||
fn grouped_fixture_with_favourite_copy() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let (mut playlist, pending) = grouped_fixture();
|
||||
let group_item = &playlist[0].channels[0];
|
||||
let fav_copy = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000099"),
|
||||
channel_group_uuid: Some(group_item.header.uuid),
|
||||
group: "My Favourites".intern(),
|
||||
..group_item.header.clone()
|
||||
},
|
||||
};
|
||||
playlist.push(PlaylistGroup {
|
||||
id: 99,
|
||||
title: "My Favourites".intern(),
|
||||
channels: vec![fav_copy],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
});
|
||||
(playlist, pending)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_get_stable_virtual_ids_and_store_is_written() {
|
||||
let (app_config, target, _dir) = persist_test_env(r#"{"channel_groups":{"enabled":true}}"#);
|
||||
let (mut playlist, mut pending) = grouped_fixture();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.expect("persists");
|
||||
let path = target_path(&app_config, &target);
|
||||
let group_vid = playlist[0].channels[0].header.virtual_id;
|
||||
let record = ChannelGroupStore::read_group(&path, group_vid).expect("reads").expect("exists");
|
||||
assert_eq!(record.members.len(), 2);
|
||||
assert!(record.members.iter().all(|m| !m.member_vid.is_unset() && m.member_vid != group_vid));
|
||||
let (via_member, idx) =
|
||||
ChannelGroupStore::read_member(&path, record.members[1].member_vid).expect("reads").expect("exists");
|
||||
assert_eq!((via_member.group_vid, idx), (group_vid, 1));
|
||||
|
||||
// second run keeps ids
|
||||
let (mut playlist2, mut pending2) = grouped_fixture();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist2,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending2,
|
||||
)
|
||||
.await
|
||||
.expect("persists");
|
||||
let record2 = ChannelGroupStore::read_group(&path, playlist2[0].channels[0].header.virtual_id).unwrap().unwrap();
|
||||
assert_eq!(record.members[0].member_vid, record2.members[0].member_vid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_keeps_vid_of_previously_ungrouped_channel() {
|
||||
let (app_config, target, _dir) = persist_test_env(r#"{"channel_groups":{"enabled":true}}"#);
|
||||
let (mut plain, _) = grouped_fixture_ungrouped();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut plain,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let korath_vid_before =
|
||||
plain[0].channels.iter().find(|c| c.header.input_name.as_ref() == "korath").unwrap().header.virtual_id;
|
||||
let (mut playlist, mut pending) = grouped_fixture();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let record =
|
||||
ChannelGroupStore::read_group(&target_path(&app_config, &target), playlist[0].channels[0].header.virtual_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.members.iter().any(|m| m.member_vid == korath_vid_before));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favourite_copy_of_group_item_is_registered_as_alias() {
|
||||
let (app_config, target, _dir) = persist_test_env(r#"{"channel_groups":{"enabled":true}}"#);
|
||||
let (mut playlist, mut pending) = grouped_fixture_with_favourite_copy();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let group_vid = playlist[0].channels[0].header.virtual_id;
|
||||
let fav_vid = playlist.iter().find(|g| g.title.as_ref() == "My Favourites").unwrap().channels[0].header.virtual_id;
|
||||
assert_ne!(fav_vid, group_vid);
|
||||
assert_eq!(ChannelGroupStore::read_alias(&target_path(&app_config, &target), fav_vid).unwrap(), Some(group_vid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_persistence_writes_no_group_store() {
|
||||
let (app_config, target, _dir) = persist_test_env_rejecting_empty();
|
||||
let mut playlist = vec![];
|
||||
let mut pending = grouped_fixture().1;
|
||||
assert!(persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!target_path(&app_config, &target).join("channel_groups.db").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_without_groups_removes_stale_group_store() {
|
||||
let (app_config, target, _dir) = persist_test_env(r#"{"channel_groups":{"enabled":true}}"#);
|
||||
let (mut playlist, mut pending) = grouped_fixture();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut plain, _) = grouped_fixture_ungrouped();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut plain,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!target_path(&app_config, &target).join("channel_groups.db").exists());
|
||||
}
|
||||
@@ -26,3 +26,7 @@ pub const STALKER_EPISODE_FILE: &str = "stalker_episode";
|
||||
pub const STALKER_SEASONS_FILE: &str = "stalker_seasons";
|
||||
pub const STALKER_SERIES_ROOTS_FILE: &str = "stalker_series_roots";
|
||||
pub const STALKER_EPG_FILE: &str = "stalker_epg";
|
||||
|
||||
pub const FILE_CHANNEL_GROUPS: &str = "channel_groups.db";
|
||||
pub const FILE_CHANNEL_GROUPS_MEMBER: &str = "channel_groups.member.db";
|
||||
pub const FILE_CHANNEL_GROUPS_ALIAS: &str = "channel_groups.alias.db";
|
||||
|
||||
@@ -122,6 +122,7 @@ pub(crate) mod tests {
|
||||
stream_channel: shared::model::StreamChannel {
|
||||
target_id: 1,
|
||||
virtual_id: 1234,
|
||||
group_virtual_id: None,
|
||||
provider_id: 1,
|
||||
input_name: shared::utils::Internable::intern("provider-input"),
|
||||
item_type: shared::model::PlaylistItemType::Live,
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
use crate::{ChannelGroupRecord, PlaylistStorageState};
|
||||
use shared::{
|
||||
model::{M3uPlaylistItem, VirtualId, XtreamPlaylistItem},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tuliprox_core::model::{AppConfig, ConfigTarget};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ResolvedStreamTarget {
|
||||
Item,
|
||||
Group(Arc<ChannelGroupRecord>),
|
||||
Member { group: Arc<ChannelGroupRecord>, index: usize },
|
||||
}
|
||||
|
||||
pub async fn classify_virtual_id(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
vid: VirtualId,
|
||||
) -> ResolvedStreamTarget {
|
||||
let target_data = playlists.channel_groups.get_or_load(app_config, playlists, target).await;
|
||||
if let Some(group) = target_data.groups.get(&vid) {
|
||||
return ResolvedStreamTarget::Group(Arc::clone(group));
|
||||
}
|
||||
if let Some(group_vid) = target_data.aliases.get(&vid) {
|
||||
if let Some(group) = target_data.groups.get(group_vid) {
|
||||
return ResolvedStreamTarget::Group(Arc::clone(group));
|
||||
}
|
||||
}
|
||||
if let Some((group_vid, index)) = target_data.members.get(&vid) {
|
||||
if let Some(group) = target_data.groups.get(group_vid) {
|
||||
return ResolvedStreamTarget::Member { group: Arc::clone(group), index: *index };
|
||||
}
|
||||
}
|
||||
ResolvedStreamTarget::Item
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn member_to_xtream_item(group: &ChannelGroupRecord, index: usize, target: &ConfigTarget) -> XtreamPlaylistItem {
|
||||
let _ = target;
|
||||
let member = &group.members[index];
|
||||
XtreamPlaylistItem {
|
||||
virtual_id: member.member_vid,
|
||||
provider_id: member.provider_id,
|
||||
name: Arc::clone(&member.title),
|
||||
logo: "".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: Arc::clone(&group.key),
|
||||
title: Arc::clone(&member.title),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: Arc::clone(&member.url),
|
||||
epg_channel_id: member.epg_channel_id.as_ref().map(Arc::clone),
|
||||
xtream_cluster: member.cluster,
|
||||
additional_properties: member.additional_properties.clone(),
|
||||
item_type: member.item_type,
|
||||
category_id: 0,
|
||||
input_name: Arc::clone(&member.input_name),
|
||||
channel_no: 0,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: Arc::clone(&member.input_stream_id),
|
||||
upstream_user_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn member_to_m3u_item(group: &ChannelGroupRecord, index: usize, target: &ConfigTarget) -> M3uPlaylistItem {
|
||||
let _ = target;
|
||||
let member = &group.members[index];
|
||||
M3uPlaylistItem {
|
||||
virtual_id: member.member_vid,
|
||||
provider_id: member.provider_id.to_string().intern(),
|
||||
name: Arc::clone(&member.title),
|
||||
chno: 0,
|
||||
logo: "".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: Arc::clone(&group.key),
|
||||
title: Arc::clone(&member.title),
|
||||
parent_code: "".intern(),
|
||||
audio_track: "".intern(),
|
||||
time_shift: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: Arc::clone(&member.url),
|
||||
epg_channel_id: member.epg_channel_id.as_ref().map(Arc::clone),
|
||||
input_name: Arc::clone(&member.input_name),
|
||||
item_type: member.item_type,
|
||||
t_stream_url: "".intern(),
|
||||
t_resource_url: None,
|
||||
t_catchup_source: None,
|
||||
t_catchup_mode: None,
|
||||
source_ordinal: 0,
|
||||
additional_properties: None,
|
||||
input_stream_id: Arc::clone(&member.input_stream_id),
|
||||
upstream_user_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
persist_playlist_views_with_groups, ChannelGroupOrigin, ChannelGroupStore, PendingChannelGroup,
|
||||
PlaylistStorageState, TargetPlaylistPersistOptions,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use shared::model::{
|
||||
ChannelGroupPolicy, ConfigPaths, ConfigTargetDto, M3uTargetOutputDto, PlaylistGroup, PlaylistItem,
|
||||
PlaylistItemHeader, PlaylistItemType, TargetOutputDto, UUIDType, XtreamCluster,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use tuliprox_core::model::{AppConfig, Config, SourcesConfig};
|
||||
|
||||
struct PersistedGroupEnv {
|
||||
app_config: Arc<AppConfig>,
|
||||
playlists: Arc<PlaylistStorageState>,
|
||||
target: ConfigTarget,
|
||||
_dir: tempfile::TempDir,
|
||||
group_vid: VirtualId,
|
||||
plain_vid: VirtualId,
|
||||
member_vids: Vec<VirtualId>,
|
||||
favourite_vid: VirtualId,
|
||||
}
|
||||
|
||||
impl PersistedGroupEnv {
|
||||
async fn republish_without_groups(&mut self) {
|
||||
let (mut plain, _) = grouped_fixture_ungrouped();
|
||||
persist_playlist_views_with_groups(
|
||||
&self.app_config,
|
||||
&mut plain,
|
||||
None,
|
||||
None,
|
||||
&self.target,
|
||||
Some(&self.playlists),
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut [],
|
||||
)
|
||||
.await
|
||||
.expect("republished");
|
||||
}
|
||||
}
|
||||
|
||||
fn test_app_config(storage_dir: &std::path::Path) -> Arc<AppConfig> {
|
||||
Arc::new(AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config {
|
||||
storage_dir: storage_dir.to_string_lossy().into_owned(),
|
||||
..Config::default()
|
||||
})),
|
||||
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
|
||||
hdhomerun: Arc::new(arc_swap::ArcSwapOption::default()),
|
||||
api_proxy: Arc::new(arc_swap::ArcSwapOption::default()),
|
||||
file_locks: Arc::new(tuliprox_core::utils::FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(arc_swap::ArcSwapOption::default()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(app_config: &AppConfig, target: &ConfigTarget) -> PathBuf {
|
||||
let config = app_config.config.load();
|
||||
crate::get_target_storage_path(&config, &target.name).expect("target storage path")
|
||||
}
|
||||
|
||||
fn grouped_fixture() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let group_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
|
||||
let group_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: group_uuid,
|
||||
id: "bbc-one".intern(),
|
||||
input_stream_id: "bbc-one".intern(),
|
||||
name: "BBC One".intern(),
|
||||
title: "BBC One".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://korath/live/101".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
channel_group_uuid: Some(group_uuid),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let normal_channel = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002"),
|
||||
id: "cnn".intern(),
|
||||
input_stream_id: "cnn".intern(),
|
||||
name: "CNN".intern(),
|
||||
title: "CNN".intern(),
|
||||
group: "News".intern(),
|
||||
url: "http://korath/live/cnn".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let member1 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011"),
|
||||
id: "201".intern(),
|
||||
input_stream_id: "201".intern(),
|
||||
name: "BBC One HD".intern(),
|
||||
title: "BBC One HD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://xandar/live/201".intern(),
|
||||
input_name: "xandar".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let member2 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012"),
|
||||
id: "101".intern(),
|
||||
input_stream_id: "101".intern(),
|
||||
name: "BBC One FHD".intern(),
|
||||
title: "BBC One FHD".intern(),
|
||||
group: "Entertainment".intern(),
|
||||
url: "http://korath/live/101".intern(),
|
||||
input_name: "korath".intern(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
};
|
||||
let playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Entertainment".intern(),
|
||||
channels: vec![group_channel],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "News".intern(),
|
||||
channels: vec![normal_channel],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
];
|
||||
let pending = vec![PendingChannelGroup {
|
||||
group_uuid,
|
||||
key: Arc::from("bbc one"),
|
||||
origin: ChannelGroupOrigin::Auto,
|
||||
policy: ChannelGroupPolicy::Priority,
|
||||
members: vec![member1, member2],
|
||||
orphaned: vec![],
|
||||
epg_split: false,
|
||||
}];
|
||||
(playlist, pending)
|
||||
}
|
||||
|
||||
fn grouped_fixture_ungrouped() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let (mut playlist, _) = grouped_fixture();
|
||||
playlist.remove(0);
|
||||
(playlist, Vec::new())
|
||||
}
|
||||
|
||||
fn grouped_fixture_with_favourite_copy() -> (Vec<PlaylistGroup>, Vec<PendingChannelGroup>) {
|
||||
let (mut playlist, pending) = grouped_fixture();
|
||||
let group_item = &playlist[0].channels[0];
|
||||
let fav_copy = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000099"),
|
||||
channel_group_uuid: Some(group_item.header.uuid),
|
||||
group: "My Favourites".intern(),
|
||||
..group_item.header.clone()
|
||||
},
|
||||
};
|
||||
playlist.push(PlaylistGroup {
|
||||
id: 99,
|
||||
title: "My Favourites".intern(),
|
||||
channels: vec![fav_copy],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
});
|
||||
(playlist, pending)
|
||||
}
|
||||
|
||||
async fn persisted_group_env() -> PersistedGroupEnv {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let app_config = test_app_config(dir.path());
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "target-group-env".to_string(),
|
||||
output: vec![TargetOutputDto::M3u(M3uTargetOutputDto {
|
||||
filename: Some("playlist.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let (mut playlist, mut pending) = grouped_fixture();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
Some(&playlists),
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.expect("persists");
|
||||
let group_vid = playlist[0].channels[0].header.virtual_id;
|
||||
let plain_vid = playlist[1].channels[0].header.virtual_id;
|
||||
let path = target_path(&app_config, &target);
|
||||
let record = ChannelGroupStore::read_group(&path, group_vid).expect("read").expect("group");
|
||||
let member_vids = record.members.iter().map(|m| m.member_vid).collect();
|
||||
PersistedGroupEnv {
|
||||
app_config,
|
||||
playlists,
|
||||
target,
|
||||
_dir: dir,
|
||||
group_vid,
|
||||
plain_vid,
|
||||
member_vids,
|
||||
favourite_vid: VirtualId::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn persisted_group_env_with_favourite() -> PersistedGroupEnv {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let app_config = test_app_config(dir.path());
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "target-fav-env".to_string(),
|
||||
output: vec![TargetOutputDto::M3u(M3uTargetOutputDto {
|
||||
filename: Some("playlist.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let (mut playlist, mut pending) = grouped_fixture_with_favourite_copy();
|
||||
persist_playlist_views_with_groups(
|
||||
&app_config,
|
||||
&mut playlist,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
Some(&playlists),
|
||||
TargetPlaylistPersistOptions::default(),
|
||||
&mut pending,
|
||||
)
|
||||
.await
|
||||
.expect("persists");
|
||||
let group_vid = playlist[0].channels[0].header.virtual_id;
|
||||
let plain_vid = playlist[1].channels[0].header.virtual_id;
|
||||
let favourite_vid =
|
||||
playlist.iter().find(|g| g.title.as_ref() == "My Favourites").unwrap().channels[0].header.virtual_id;
|
||||
let path = target_path(&app_config, &target);
|
||||
let record = ChannelGroupStore::read_group(&path, group_vid).expect("read").expect("group");
|
||||
let member_vids = record.members.iter().map(|m| m.member_vid).collect();
|
||||
PersistedGroupEnv { app_config, playlists, target, _dir: dir, group_vid, plain_vid, member_vids, favourite_vid }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classify_distinguishes_item_group_and_member() {
|
||||
let env = persisted_group_env().await;
|
||||
assert!(matches!(
|
||||
classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.plain_vid).await,
|
||||
ResolvedStreamTarget::Item
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.group_vid).await,
|
||||
ResolvedStreamTarget::Group(_)
|
||||
));
|
||||
match classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.member_vids[1]).await {
|
||||
ResolvedStreamTarget::Member { group, index } => {
|
||||
assert_eq!(group.group_vid, env.group_vid);
|
||||
assert_eq!(index, 1);
|
||||
}
|
||||
other => panic!("expected member, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favourite_alias_vid_resolves_to_group() {
|
||||
let env = persisted_group_env_with_favourite().await;
|
||||
match classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.favourite_vid).await {
|
||||
ResolvedStreamTarget::Group(group) => assert_eq!(group.group_vid, env.group_vid),
|
||||
other => panic!("expected group, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_item_carries_member_input_and_url() {
|
||||
let env = persisted_group_env().await;
|
||||
let ResolvedStreamTarget::Group(group) =
|
||||
classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.group_vid).await
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let item = member_to_xtream_item(&group, 1, &env.target);
|
||||
assert_eq!(item.virtual_id, env.member_vids[1]);
|
||||
assert_eq!(item.input_name.as_ref(), "korath");
|
||||
assert!(item.url.contains("korath"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_reloads_after_republish() {
|
||||
let mut env = persisted_group_env().await;
|
||||
let _ = classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.group_vid).await;
|
||||
env.republish_without_groups().await;
|
||||
assert!(matches!(
|
||||
classify_virtual_id(&env.app_config, &env.playlists, &env.target, env.group_vid).await,
|
||||
ResolvedStreamTarget::Item
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_
|
||||
log.workspace = true
|
||||
rand = "0.9.5"
|
||||
reqwest = { version = "0.13.4", features = ["json", "stream", "rustls", "socks", "form", "query"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
lru = "0.16.4"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "fs", "time", "macros", "sync"] }
|
||||
|
||||
@@ -22,8 +22,8 @@ const EVICTED_PROVIDER_RELEASE_POLL_INTERVAL: Duration = Duration::from_millis(1
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ProviderReleaseSnapshot {
|
||||
pub addr: SocketAddr,
|
||||
single_allocations: Vec<AllocationId>,
|
||||
shared_subscribers: Vec<SharedSubscriberId>,
|
||||
pub(crate) single_allocations: Vec<AllocationId>,
|
||||
pub(crate) shared_subscribers: Vec<SharedSubscriberId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -97,6 +97,7 @@ mod tests;
|
||||
mod account_health;
|
||||
mod allocation;
|
||||
mod capacity;
|
||||
mod capacity_probe;
|
||||
mod leases;
|
||||
mod preemption;
|
||||
mod release;
|
||||
@@ -109,3 +110,4 @@ use self::{
|
||||
release::{ProviderAllocationGuard, ReleaseAction},
|
||||
shared::{SharedConnectionId, SharedConnections},
|
||||
};
|
||||
pub use capacity_probe::*;
|
||||
|
||||
@@ -32,6 +32,10 @@ use tuliprox_core::{
|
||||
|
||||
impl ProviderReleaseSnapshot {
|
||||
pub fn is_empty(&self) -> bool { self.single_allocations.is_empty() && self.shared_subscribers.is_empty() }
|
||||
|
||||
pub(crate) fn from_single_allocations(addr: SocketAddr, single_allocations: Vec<AllocationId>) -> Self {
|
||||
Self { addr, single_allocations, shared_subscribers: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a public live HLS session token to its stable provider playback owner.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::{AcquireProviderParams, ActiveProviderManager, ConnectionKind, OwnerProviderPin, PlaybackLeaseRef};
|
||||
pub use shared::model::CapacityTier;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::SocketAddr,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
};
|
||||
use tuliprox_core::model::{AllocationId, ProviderAllocation, ProviderHandle};
|
||||
|
||||
impl ActiveProviderManager {
|
||||
pub(super) fn probe_connection_candidate(
|
||||
&self,
|
||||
provider_or_input_name: &Arc<str>,
|
||||
allow_grace: bool,
|
||||
session_owner: Option<&str>,
|
||||
) -> ProviderAllocation {
|
||||
if let Some(owner) = session_owner {
|
||||
match self.owner_provider_pin(provider_or_input_name, owner) {
|
||||
Some(OwnerProviderPin::Reserved(reserved_provider)) => {
|
||||
let allocation =
|
||||
self.providers.probe_exact_connection_with_grace_override(&reserved_provider, allow_grace);
|
||||
if matches!(allocation, ProviderAllocation::Exhausted)
|
||||
|| self.exceeds_reserved_capacity(&reserved_provider, Some(owner))
|
||||
{
|
||||
return ProviderAllocation::Exhausted;
|
||||
}
|
||||
return allocation;
|
||||
}
|
||||
Some(OwnerProviderPin::Preferred(affinity_provider)) => {
|
||||
let allocation =
|
||||
self.providers.probe_exact_connection_with_grace_override(&affinity_provider, false);
|
||||
if !matches!(allocation, ProviderAllocation::Exhausted)
|
||||
&& !self.exceeds_reserved_capacity(&affinity_provider, Some(owner))
|
||||
{
|
||||
return allocation;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
let candidate_count = self.providers.provider_names_for_input(provider_or_input_name).len();
|
||||
let attempts = candidate_count.max(1);
|
||||
let mut skipped_reserved = HashSet::new();
|
||||
for _ in 0..attempts {
|
||||
let allocation = self.providers.probe_connection_with_grace_override_excluding(
|
||||
provider_or_input_name,
|
||||
allow_grace,
|
||||
&skipped_reserved,
|
||||
);
|
||||
if matches!(allocation, ProviderAllocation::Exhausted) {
|
||||
break;
|
||||
}
|
||||
if let Some(provider_name) = allocation.get_provider_name() {
|
||||
if self.exceeds_reserved_capacity(&provider_name, session_owner) {
|
||||
skipped_reserved.insert(provider_name);
|
||||
if skipped_reserved.len() >= attempts {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return allocation;
|
||||
}
|
||||
|
||||
ProviderAllocation::Exhausted
|
||||
}
|
||||
|
||||
pub fn capacity_probe(
|
||||
&self,
|
||||
input_name: &Arc<str>,
|
||||
priority: i8,
|
||||
kind: ConnectionKind,
|
||||
session_owner: Option<&str>,
|
||||
own_releasing: &[AllocationId],
|
||||
) -> CapacityTier {
|
||||
let free_probe = self.probe_connection_candidate(input_name, false, session_owner);
|
||||
if matches!(free_probe, ProviderAllocation::Available(_)) {
|
||||
return CapacityTier::Free;
|
||||
}
|
||||
|
||||
if !own_releasing.is_empty() {
|
||||
let connections = self.read_connections();
|
||||
let has_own_release = own_releasing.iter().any(|alloc_id| {
|
||||
if let Some(info) = connections.single.get(alloc_id) {
|
||||
if let Some(prov_name) = info.allocation.get_provider_name() {
|
||||
if self.providers.is_provider_for_input(&prov_name, input_name) || &prov_name == input_name {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key) = connections.shared.shared_by_allocation_id.get(alloc_id) {
|
||||
if let Some(shared) = connections.shared.by_key.get(key) {
|
||||
if let Some(prov_name) = shared.allocation.get_provider_name() {
|
||||
if self.providers.is_provider_for_input(&prov_name, input_name) || &prov_name == input_name
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
if has_own_release {
|
||||
return CapacityTier::OwnRelease;
|
||||
}
|
||||
}
|
||||
|
||||
let grace_probe = self.probe_connection_candidate(input_name, true, session_owner);
|
||||
if matches!(grace_probe, ProviderAllocation::Available(_) | ProviderAllocation::GracePeriod(_)) {
|
||||
return CapacityTier::Grace;
|
||||
}
|
||||
|
||||
let reserved_providers = self.reserved_provider_names_for_other(input_name, session_owner);
|
||||
let has_preempt_candidate = {
|
||||
let connections = self.read_connections();
|
||||
self.select_preemption_candidate(&connections, input_name, priority, kind, &reserved_providers).is_some()
|
||||
};
|
||||
if has_preempt_candidate {
|
||||
return CapacityTier::Preempt;
|
||||
}
|
||||
|
||||
CapacityTier::Exhausted
|
||||
}
|
||||
|
||||
pub fn try_acquire_connection_no_preempt(
|
||||
&self,
|
||||
input_name: &Arc<str>,
|
||||
allow_grace: bool,
|
||||
addr: &SocketAddr,
|
||||
priority: i8,
|
||||
kind: ConnectionKind,
|
||||
lease: Option<PlaybackLeaseRef<'_>>,
|
||||
) -> Option<ProviderHandle> {
|
||||
let params = AcquireProviderParams { addr, priority, kind, lease };
|
||||
if self.is_shutting_down.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
let _transition = self.lock_capacity_transition();
|
||||
if self.is_shutting_down.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
self.acquire_connection_inner_no_preempt(input_name, allow_grace, ¶ms)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use super::{ActiveProviderManager, ConnectionKind, PlaybackLeaseRef};
|
||||
|
||||
mod admission;
|
||||
mod behavior;
|
||||
mod capacity_probe;
|
||||
mod lifecycle;
|
||||
mod retry;
|
||||
mod support;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use super::super::{ActiveProviderManager, CapacityTier, ConnectionKind};
|
||||
use crate::EventManager;
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::{
|
||||
model::{ConfigPaths, InputType, ReverseProxyConfigDto, StreamConfigDto},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tuliprox_core::{
|
||||
model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, ReverseProxyConfig, SourcesConfig},
|
||||
utils::FileLockManager,
|
||||
};
|
||||
|
||||
fn addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) }
|
||||
|
||||
fn app_config_with_sources_and_grace(sources: SourcesConfig, grace_period_millis: u64) -> AppConfig {
|
||||
let mut config = Config::default();
|
||||
let dto = ReverseProxyConfigDto {
|
||||
stream: Some(StreamConfigDto { grace_period_millis, ..StreamConfigDto::default() }),
|
||||
..ReverseProxyConfigDto::default()
|
||||
};
|
||||
config.reverse_proxy = Some(ReverseProxyConfig::from(&dto));
|
||||
|
||||
AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(config)),
|
||||
sources: Arc::new(ArcSwap::from_pointee(sources)),
|
||||
hdhomerun: Arc::new(ArcSwapOption::default()),
|
||||
api_proxy: Arc::new(ArcSwapOption::default()),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::default()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(MediaToolCapabilities::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_with_inputs(inputs: &[(&str, u16)]) -> ActiveProviderManager {
|
||||
let sources = SourcesConfig {
|
||||
inputs: inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, max))| {
|
||||
Arc::new(ConfigInput {
|
||||
id: u16::try_from(i + 1).unwrap(),
|
||||
name: (*name).intern(),
|
||||
input_type: InputType::Xtream,
|
||||
url: format!("http://{name}.invalid"),
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
enabled: true,
|
||||
max_connections: *max,
|
||||
..ConfigInput::default()
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
..SourcesConfig::default()
|
||||
};
|
||||
ActiveProviderManager::new(&app_config_with_sources_and_grace(sources, 2_000), &Arc::new(EventManager::new()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_tiers_without_allocating() {
|
||||
let manager = manager_with_inputs(&[("solis", 1)]);
|
||||
let solis: Arc<str> = "solis".into();
|
||||
assert_eq!(manager.capacity_probe(&solis, 0, ConnectionKind::Normal, None, &[]), CapacityTier::Free);
|
||||
let held = manager.acquire_connection(&solis, &addr(1), -1, ConnectionKind::Normal).expect("slot");
|
||||
assert_eq!(manager.capacity_probe(&solis, 0, ConnectionKind::Normal, None, &[]), CapacityTier::Grace);
|
||||
let total: usize = manager.active_connections().unwrap_or_default().values().sum();
|
||||
assert_eq!(total, 1);
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_pending_release_is_reported_as_own_release() {
|
||||
let manager = manager_with_inputs(&[("solis", 1)]);
|
||||
let solis: Arc<str> = "solis".into();
|
||||
let held = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("slot");
|
||||
let own = [held.allocation_id];
|
||||
assert_eq!(manager.capacity_probe(&solis, 0, ConnectionKind::Normal, None, &own), CapacityTier::OwnRelease);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_priority_sees_preempt_when_grace_is_used() {
|
||||
let manager = manager_with_inputs(&[("solis", 1)]);
|
||||
let solis: Arc<str> = "solis".into();
|
||||
let _low = manager.acquire_connection(&solis, &addr(1), 10, ConnectionKind::Normal).expect("slot");
|
||||
let _grace = manager
|
||||
.try_acquire_connection_no_preempt(&solis, true, &addr(2), 10, ConnectionKind::Normal, None)
|
||||
.expect("grace");
|
||||
assert_eq!(manager.capacity_probe(&solis, -10, ConnectionKind::Normal, None, &[]), CapacityTier::Preempt);
|
||||
assert_eq!(manager.capacity_probe(&solis, 10, ConnectionKind::Normal, None, &[]), CapacityTier::Exhausted);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
next_session_incarnation, remember_session_addr, url_host_key, ActiveUserManager, CreateUserSessionParams,
|
||||
PlaybackLifecycle, SessionChangeSignal, SessionIdentity, SocketRegistration, UserConnectionData, UserSession,
|
||||
UserSessionParams,
|
||||
MemberBinding, PlaybackLifecycle, SessionChangeSignal, SessionIdentity, SocketRegistration, UserConnectionData,
|
||||
UserSession, UserSessionParams,
|
||||
};
|
||||
use shared::{
|
||||
model::UserConnectionPermission,
|
||||
@@ -40,6 +40,7 @@ impl ActiveUserManager {
|
||||
permission: params.connection_permission,
|
||||
connection_kind: params.connection_kind,
|
||||
lifecycle: PlaybackLifecycle::Prepared,
|
||||
member_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,4 +305,14 @@ impl ActiveUserManager {
|
||||
|
||||
Some(connection_data.sessions[session_index].clone())
|
||||
}
|
||||
|
||||
pub async fn bind_member(&self, username: &str, token: &str, binding: MemberBinding) {
|
||||
let mut user_connections = self.write_connections().await;
|
||||
let Some(connection_data) = user_connections.by_key.get_mut(username) else {
|
||||
return;
|
||||
};
|
||||
if let Some(session) = connection_data.sessions.iter_mut().find(|s| s.token == token) {
|
||||
session.member_binding = Some(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use jsonwebtoken::get_current_timestamp;
|
||||
use log::debug;
|
||||
use shared::model::{StreamInfo, UserConnectionPermission};
|
||||
use std::{collections::HashSet, net::SocketAddr, sync::atomic::Ordering};
|
||||
use tuliprox_core::model::AllocationId;
|
||||
|
||||
/// Panel identity of a removed stream row, as carried by `DisconnectedStream`.
|
||||
pub(super) type RemovedStreamKey = (SocketAddr, u32);
|
||||
@@ -185,6 +186,21 @@ impl UserConnectionData {
|
||||
}
|
||||
|
||||
impl ActiveUserManager {
|
||||
pub async fn pending_release_allocations(&self, username: &str) -> Vec<AllocationId> {
|
||||
self.pending_provider_release(username).await.map_or_else(Vec::new, |snapshot| snapshot.single_allocations)
|
||||
}
|
||||
|
||||
pub async fn set_pending_release_for_test(&self, username: &str, allocations: &[AllocationId]) {
|
||||
self.set_pending_provider_release(
|
||||
username,
|
||||
crate::active_provider_manager::ProviderReleaseSnapshot::from_single_allocations(
|
||||
SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
allocations.to_vec(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn pending_provider_release(&self, username: &str) -> Option<ProviderReleaseSnapshot> {
|
||||
self.pending_provider_releases.lock().await.get(username).cloned()
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ pub enum SessionProviderHeaders {
|
||||
Headers(HashMap<String, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemberBinding {
|
||||
pub group_vid: u32,
|
||||
pub member_vid: u32,
|
||||
pub member_index: usize,
|
||||
pub input_name: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UserSession {
|
||||
pub token: String,
|
||||
@@ -140,6 +148,7 @@ pub struct UserSession {
|
||||
pub permission: UserConnectionPermission,
|
||||
pub connection_kind: Option<ConnectionKind>,
|
||||
pub lifecycle: PlaybackLifecycle,
|
||||
pub member_binding: Option<MemberBinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -79,6 +79,26 @@ impl ActiveUserManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect active session virtual ids for a user.
|
||||
pub async fn active_session_vids(&self, username: &str) -> Vec<u32> {
|
||||
let connections = self.connections.read().await;
|
||||
let Some(data) = connections.by_key.get(username) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if data.sessions.is_empty() {
|
||||
data.streams.iter().filter(|s| !s.preserved).map(|s| s.channel.virtual_id).collect()
|
||||
} else {
|
||||
data.sessions.iter().map(|s| s.virtual_id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read bound member input name for a user session if any.
|
||||
pub async fn bound_member_input(&self, username: &str) -> Option<Arc<str>> {
|
||||
let connections = self.connections.read().await;
|
||||
let data = connections.by_key.get(username)?;
|
||||
data.sessions.iter().find_map(|s| s.member_binding.as_ref().map(|b| Arc::clone(&b.input_name)))
|
||||
}
|
||||
|
||||
pub(super) async fn log_active_user(&self) {
|
||||
let is_log_user_enabled = self.is_log_user_enabled();
|
||||
let (user_count, user_connection_count) = { self.active_users_and_connections().await };
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Default for UserSession {
|
||||
permission: UserConnectionPermission::Allowed,
|
||||
connection_kind: None,
|
||||
lifecycle: PlaybackLifecycle::default(),
|
||||
member_binding: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ async fn check_divergence_detects_stream_without_counted_session() {
|
||||
epg_channel_id: None,
|
||||
epg_reference_ts: None,
|
||||
upstream_user_agent: None,
|
||||
group_virtual_id: None,
|
||||
},
|
||||
user_agent: "ua".to_string(),
|
||||
country_code: None,
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(in crate::active_user_manager::tests) fn test_channel(virtual_id: u32) -> St
|
||||
epg_channel_id: None,
|
||||
epg_reference_ts: None,
|
||||
upstream_user_agent: None,
|
||||
group_virtual_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ pub(in crate::active_user_manager::tests) fn test_adaptive_channel(virtual_id: u
|
||||
epg_channel_id: None,
|
||||
epg_reference_ts: None,
|
||||
upstream_user_agent: None,
|
||||
group_virtual_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
use axum::http::StatusCode;
|
||||
use dashmap::DashMap;
|
||||
use shared::model::ChannelGroupSelectionConfig;
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub type MemberKey = (Arc<str>, Arc<str>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailureClass {
|
||||
Stream,
|
||||
Transport,
|
||||
Auth,
|
||||
Capacity,
|
||||
}
|
||||
|
||||
impl FailureClass {
|
||||
pub fn from_status(status: StatusCode) -> Self {
|
||||
match status.as_u16() {
|
||||
401 | 403 => Self::Auth,
|
||||
429 | 509 => Self::Capacity,
|
||||
_ => Self::Stream,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opens_member_circuit(self) -> bool { !matches!(self, Self::Capacity) }
|
||||
|
||||
pub fn counts_for_input(self) -> bool { matches!(self, Self::Transport | Self::Auth) }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CircuitView {
|
||||
Closed,
|
||||
Counting(u8),
|
||||
Open { remaining: Duration },
|
||||
HalfOpenInFlight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CircuitPolicy {
|
||||
pub failures: u8,
|
||||
pub cooldown: Duration,
|
||||
pub cooldown_max: Duration,
|
||||
pub input_failures: u8,
|
||||
pub input_window: Duration,
|
||||
}
|
||||
|
||||
impl CircuitPolicy {
|
||||
pub fn new(failures: u8, cooldown: Duration, cooldown_max: Duration) -> Self {
|
||||
Self { failures, cooldown, cooldown_max, input_failures: 3, input_window: Duration::from_secs(120) }
|
||||
}
|
||||
|
||||
pub fn with_input(mut self, input_failures: u8, input_window: Duration) -> Self {
|
||||
self.input_failures = input_failures;
|
||||
self.input_window = input_window;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitPolicy {
|
||||
fn default() -> Self { Self::new(3, Duration::from_secs(60), Duration::from_mins(15)) }
|
||||
}
|
||||
|
||||
impl From<&ChannelGroupSelectionConfig> for CircuitPolicy {
|
||||
fn from(cfg: &ChannelGroupSelectionConfig) -> Self {
|
||||
Self {
|
||||
failures: cfg.circuit_failures,
|
||||
cooldown: Duration::from_secs(cfg.circuit_cooldown_secs),
|
||||
cooldown_max: Duration::from_secs(cfg.circuit_cooldown_max_secs),
|
||||
input_failures: cfg.input_circuit_failures,
|
||||
input_window: Duration::from_secs(cfg.input_failure_window_secs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use shared::model::CircuitState;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CircuitEntry {
|
||||
pub failures: u8,
|
||||
pub open_level: u8,
|
||||
pub state: CircuitState,
|
||||
}
|
||||
|
||||
impl Default for CircuitEntry {
|
||||
fn default() -> Self { Self { failures: 0, open_level: 0, state: CircuitState::Closed } }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InputCircuitEntry {
|
||||
pub circuit: CircuitEntry,
|
||||
pub recent: VecDeque<(MemberKey, Instant)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CircuitSharedState {
|
||||
pub circuits: DashMap<MemberKey, CircuitEntry>,
|
||||
pub input_circuits: DashMap<Arc<str>, InputCircuitEntry>,
|
||||
}
|
||||
|
||||
pub struct HalfOpenGuard {
|
||||
pub(crate) member_key: MemberKey,
|
||||
pub(crate) input_name: Option<Arc<str>>,
|
||||
pub(crate) admit_time: Instant,
|
||||
pub(crate) shared: Arc<CircuitSharedState>,
|
||||
pub(crate) consumed: bool,
|
||||
}
|
||||
|
||||
impl Drop for HalfOpenGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.consumed {
|
||||
if let Some(mut entry) = self.shared.circuits.get_mut(&self.member_key) {
|
||||
if entry.state == CircuitState::HalfOpenInFlight {
|
||||
entry.state = CircuitState::OpenUntil(self.admit_time);
|
||||
}
|
||||
}
|
||||
if let Some(input) = &self.input_name {
|
||||
if let Some(mut entry) = self.shared.input_circuits.get_mut(input) {
|
||||
if entry.circuit.state == CircuitState::HalfOpenInFlight {
|
||||
entry.circuit.state = CircuitState::OpenUntil(self.admit_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CircuitAdmission {
|
||||
Closed,
|
||||
HalfOpen(HalfOpenGuard),
|
||||
Open,
|
||||
}
|
||||
|
||||
pub(crate) fn compute_cooldown(policy: &CircuitPolicy, open_level: u8) -> Duration {
|
||||
let multiplier = 1u32.checked_shl(u32::from(open_level.min(16))).unwrap_or(u32::MAX);
|
||||
let scaled = policy.cooldown.saturating_mul(multiplier);
|
||||
scaled.min(policy.cooldown_max)
|
||||
}
|
||||
|
||||
pub(crate) fn record_input_failure_in_shared(
|
||||
shared: &CircuitSharedState,
|
||||
key: &MemberKey,
|
||||
now: Instant,
|
||||
policy: &CircuitPolicy,
|
||||
) {
|
||||
let mut entry = shared.input_circuits.entry(Arc::clone(&key.0)).or_default();
|
||||
entry.recent.push_back((key.clone(), now));
|
||||
let window = policy.input_window;
|
||||
entry.recent.retain(|(_, ts)| if *ts <= now { now.duration_since(*ts) <= window } else { true });
|
||||
while entry.recent.len() > usize::from(policy.input_failures) * 4 {
|
||||
entry.recent.pop_front();
|
||||
}
|
||||
let mut distinct = HashSet::new();
|
||||
for (k, _) in &entry.recent {
|
||||
distinct.insert(k);
|
||||
if distinct.len() >= 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if entry.recent.len() >= usize::from(policy.input_failures)
|
||||
&& distinct.len() >= 2
|
||||
&& entry.circuit.state == CircuitState::Closed
|
||||
{
|
||||
let cooldown = compute_cooldown(policy, entry.circuit.open_level);
|
||||
entry.circuit.state = CircuitState::OpenUntil(now + cooldown);
|
||||
entry.circuit.open_level = entry.circuit.open_level.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channel_group::health::MemberHealthIndex;
|
||||
|
||||
fn key(input: &str, stream_id: &str) -> MemberKey { (input.into(), stream_id.into()) }
|
||||
|
||||
#[test]
|
||||
fn opens_after_threshold_and_backs_off_exponentially() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(2, Duration::from_secs(60), Duration::from_mins(15)));
|
||||
let k = key("solis", "1");
|
||||
let t0 = Instant::now();
|
||||
index.record_failure(&k, t0);
|
||||
assert!(matches!(index.admit(&k, t0), CircuitAdmission::Closed));
|
||||
index.record_failure(&k, t0);
|
||||
assert!(matches!(index.admit(&k, t0), CircuitAdmission::Open));
|
||||
let t1 = t0 + Duration::from_secs(61);
|
||||
let CircuitAdmission::HalfOpen(guard) = index.admit(&k, t1) else { panic!("expected half-open") };
|
||||
index.record_failure_with(guard, t1);
|
||||
assert!(matches!(index.admit(&k, t1 + Duration::from_secs(119)), CircuitAdmission::Open));
|
||||
assert!(matches!(index.admit(&k, t1 + Duration::from_secs(121)), CircuitAdmission::HalfOpen(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_is_single_flight_under_concurrency() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(1, Duration::from_secs(1), Duration::from_secs(8)));
|
||||
let k = key("korath", "7");
|
||||
let t0 = Instant::now();
|
||||
index.record_failure(&k, t0);
|
||||
let later = t0 + Duration::from_secs(2);
|
||||
let granted = std::sync::Mutex::new(Vec::new());
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..16 {
|
||||
scope.spawn(|| {
|
||||
if let CircuitAdmission::HalfOpen(guard) = index.admit(&k, later) {
|
||||
granted.lock().expect("lock").push(guard);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(granted.lock().expect("lock").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_half_open_guard_allows_next_probe() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(1, Duration::from_secs(1), Duration::from_secs(8)));
|
||||
let k = key("veyra", "3");
|
||||
let t0 = Instant::now();
|
||||
index.record_failure(&k, t0);
|
||||
let later = t0 + Duration::from_secs(2);
|
||||
drop(index.admit(&k, later));
|
||||
assert!(matches!(index.admit(&k, later), CircuitAdmission::HalfOpen(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_closes_and_resets_backoff() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(1, Duration::from_secs(1), Duration::from_secs(8)));
|
||||
let k = key("orun", "4");
|
||||
let t0 = Instant::now();
|
||||
index.record_failure(&k, t0);
|
||||
let CircuitAdmission::HalfOpen(guard) = index.admit(&k, t0 + Duration::from_secs(2)) else { panic!() };
|
||||
index.record_success_with(guard);
|
||||
assert!(matches!(index.admit(&k, t0 + Duration::from_secs(2)), CircuitAdmission::Closed));
|
||||
assert!(matches!(index.circuit_view(&k, t0 + Duration::from_secs(2)), CircuitView::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_members_without_provider_id_do_not_share_a_circuit() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(1, Duration::from_secs(60), Duration::from_secs(60)));
|
||||
let t0 = Instant::now();
|
||||
index.record_failure(&key("m3u-in", "chan-a"), t0);
|
||||
assert!(matches!(index.admit(&key("m3u-in", "chan-a"), t0), CircuitAdmission::Open));
|
||||
assert!(matches!(index.admit(&key("m3u-in", "chan-b"), t0), CircuitAdmission::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_circuit_needs_two_distinct_members() {
|
||||
let index = MemberHealthIndex::new(
|
||||
CircuitPolicy::new(5, Duration::from_secs(60), Duration::from_secs(60))
|
||||
.with_input(3, Duration::from_secs(120)),
|
||||
);
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..3 {
|
||||
index.record_classified_failure(&key("solis", "1"), FailureClass::Transport, t0);
|
||||
}
|
||||
assert!(matches!(index.input_circuit_view("solis", t0), CircuitView::Closed | CircuitView::Counting(_)));
|
||||
index.record_classified_failure(&key("solis", "2"), FailureClass::Auth, t0);
|
||||
assert!(matches!(index.input_circuit_view("solis", t0), CircuitView::Open { .. }));
|
||||
assert!(matches!(index.admit(&key("solis", "77"), t0), CircuitAdmission::Open));
|
||||
assert!(matches!(index.admit(&key("korath", "1"), t0), CircuitAdmission::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_failures_outside_window_do_not_count() {
|
||||
let index = MemberHealthIndex::new(
|
||||
CircuitPolicy::new(5, Duration::from_secs(60), Duration::from_secs(60))
|
||||
.with_input(2, Duration::from_secs(120)),
|
||||
);
|
||||
let t0 = Instant::now();
|
||||
index.record_classified_failure(&key("solis", "1"), FailureClass::Transport, t0);
|
||||
index.record_classified_failure(&key("solis", "2"), FailureClass::Transport, t0 + Duration::from_secs(121));
|
||||
assert!(!matches!(index.input_circuit_view("solis", t0 + Duration::from_secs(121)), CircuitView::Open { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_failure_never_opens_a_circuit() {
|
||||
let index = MemberHealthIndex::new(
|
||||
CircuitPolicy::new(1, Duration::from_secs(60), Duration::from_secs(60))
|
||||
.with_input(2, Duration::from_secs(120)),
|
||||
);
|
||||
let t0 = Instant::now();
|
||||
index.record_classified_failure(&key("solis", "1"), FailureClass::Capacity, t0);
|
||||
index.record_classified_failure(&key("solis", "2"), FailureClass::Capacity, t0);
|
||||
assert!(matches!(index.admit(&key("solis", "1"), t0), CircuitAdmission::Closed));
|
||||
assert!(matches!(index.input_circuit_view("solis", t0), CircuitView::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_class_from_status() {
|
||||
assert_eq!(FailureClass::from_status(StatusCode::UNAUTHORIZED), FailureClass::Auth);
|
||||
assert_eq!(FailureClass::from_status(StatusCode::FORBIDDEN), FailureClass::Auth);
|
||||
assert_eq!(FailureClass::from_status(StatusCode::TOO_MANY_REQUESTS), FailureClass::Capacity);
|
||||
assert_eq!(FailureClass::from_status(StatusCode::from_u16(509).unwrap()), FailureClass::Capacity);
|
||||
assert_eq!(FailureClass::from_status(StatusCode::NOT_FOUND), FailureClass::Stream);
|
||||
assert_eq!(FailureClass::from_status(StatusCode::SERVICE_UNAVAILABLE), FailureClass::Stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
use super::circuit::{
|
||||
compute_cooldown, record_input_failure_in_shared, CircuitAdmission, CircuitPolicy, CircuitSharedState,
|
||||
CircuitState, CircuitView, FailureClass, HalfOpenGuard, MemberKey,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use shared::model::{AudioChannels, AudioCodec, MediaQuality, VideoCodec, VideoResolution};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tuliprox_repository::GroupMember;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum QosKey {
|
||||
Provider { input: Arc<str>, provider_id: u32 },
|
||||
TargetMember { target: Arc<str>, member_vid: u32 },
|
||||
}
|
||||
|
||||
pub fn member_key(member: &GroupMember) -> MemberKey {
|
||||
(Arc::clone(&member.input_name), Arc::clone(&member.input_stream_id))
|
||||
}
|
||||
|
||||
pub fn qos_key(target_name: &Arc<str>, member: &GroupMember) -> QosKey {
|
||||
if member.provider_id > 0 {
|
||||
QosKey::Provider { input: Arc::clone(&member.input_name), provider_id: member.provider_id }
|
||||
} else {
|
||||
QosKey::TargetMember { target: Arc::clone(target_name), member_vid: member.member_vid.get() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct QosPart {
|
||||
pub score_24h: u8,
|
||||
pub score_7d: u8,
|
||||
pub confidence: u8,
|
||||
pub failure_streak: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbePart {
|
||||
pub resolution: VideoResolution,
|
||||
pub codec: VideoCodec,
|
||||
pub bitrate_kbps: u32,
|
||||
pub audio_codec: AudioCodec,
|
||||
pub audio_channels: AudioChannels,
|
||||
pub audio_language: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemberHealth {
|
||||
pub qos: Option<QosPart>,
|
||||
pub probe: Option<ProbePart>,
|
||||
pub circuit: CircuitView,
|
||||
}
|
||||
|
||||
pub struct MemberHealthIndex {
|
||||
qos: ArcSwap<HashMap<QosKey, QosPart>>,
|
||||
probe: ArcSwap<HashMap<MemberKey, ProbePart>>,
|
||||
shared: Arc<CircuitSharedState>,
|
||||
policy: ArcSwap<CircuitPolicy>,
|
||||
event_sink: ArcSwap<Option<Arc<dyn shared::model::EventSink>>>,
|
||||
}
|
||||
|
||||
impl Default for MemberHealthIndex {
|
||||
fn default() -> Self { Self::new(CircuitPolicy::default()) }
|
||||
}
|
||||
|
||||
impl MemberHealthIndex {
|
||||
pub fn new(policy: CircuitPolicy) -> Self {
|
||||
Self {
|
||||
qos: ArcSwap::from_pointee(HashMap::new()),
|
||||
probe: ArcSwap::from_pointee(HashMap::new()),
|
||||
shared: Arc::new(CircuitSharedState::default()),
|
||||
policy: ArcSwap::from_pointee(policy),
|
||||
event_sink: ArcSwap::from_pointee(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_event_sink(&self, sink: Arc<dyn shared::model::EventSink>) {
|
||||
self.event_sink.store(Arc::new(Some(sink)));
|
||||
}
|
||||
|
||||
fn emit_circuit_changed(&self, input_name: Arc<str>, input_stream_id: Arc<str>, state: CircuitState) {
|
||||
if let Some(sink) = self.event_sink.load().as_ref() {
|
||||
sink.emit(shared::model::EventMessage::ChannelGroupCircuitChanged { input_name, input_stream_id, state });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health(&self, target_name: &Arc<str>, member: &GroupMember, now: Instant) -> MemberHealth {
|
||||
let q_key = qos_key(target_name, member);
|
||||
let qos = self.qos.load().get(&q_key).copied();
|
||||
let m_key = member_key(member);
|
||||
let probe = self.probe.load().get(&m_key).cloned();
|
||||
let circuit = self.circuit_view(&m_key, now);
|
||||
MemberHealth { qos, probe, circuit }
|
||||
}
|
||||
|
||||
pub fn replace_qos(&self, entries: impl IntoIterator<Item = (QosKey, QosPart)>) {
|
||||
let mut map = HashMap::new();
|
||||
for (k, v) in entries {
|
||||
match map.entry(k) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(v);
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
if v.confidence >= e.get().confidence {
|
||||
e.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.qos.store(Arc::new(map));
|
||||
}
|
||||
|
||||
pub fn replace_qos_merge(&self, entries: impl IntoIterator<Item = (QosKey, QosPart)>) {
|
||||
let mut map = (**self.qos.load()).clone();
|
||||
for (k, v) in entries {
|
||||
match map.entry(k) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(v);
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
if v.confidence >= e.get().confidence {
|
||||
e.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.qos.store(Arc::new(map));
|
||||
}
|
||||
|
||||
pub fn patch_probe(&self, key: &MemberKey, video_json: Option<&str>, bitrate_kbps: u32) {
|
||||
let quality = MediaQuality::from_ffprobe_info(None, video_json);
|
||||
let (resolution, codec) =
|
||||
quality.map_or((VideoResolution::default(), VideoCodec::default()), |q| (q.resolution, q.video_codec));
|
||||
|
||||
self.probe.rcu(|current| {
|
||||
let mut map = (**current).clone();
|
||||
let (audio_codec, audio_channels, audio_language) =
|
||||
map.get(key).map_or((AudioCodec::default(), AudioChannels::default(), None), |p| {
|
||||
(p.audio_codec.clone(), p.audio_channels.clone(), p.audio_language.clone())
|
||||
});
|
||||
map.insert(
|
||||
key.clone(),
|
||||
ProbePart {
|
||||
resolution: resolution.clone(),
|
||||
codec: codec.clone(),
|
||||
bitrate_kbps,
|
||||
audio_codec,
|
||||
audio_channels,
|
||||
audio_language,
|
||||
},
|
||||
);
|
||||
Arc::new(map)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn patch_probe_audio(&self, key: &MemberKey, audio_json: Option<&str>) {
|
||||
let quality = MediaQuality::from_ffprobe_info(audio_json, None);
|
||||
let (audio_codec, audio_channels) =
|
||||
quality.map_or((AudioCodec::default(), AudioChannels::default()), |q| (q.audio_codec, q.audio_channels));
|
||||
|
||||
let audio_language = audio_json.and_then(|raw| {
|
||||
let val = serde_json::from_str::<serde_json::Value>(raw).ok()?;
|
||||
let obj = match &val {
|
||||
serde_json::Value::Object(map) => Some(map),
|
||||
serde_json::Value::Array(arr) => arr.first().and_then(serde_json::Value::as_object),
|
||||
_ => None,
|
||||
}?;
|
||||
let lang = obj
|
||||
.get("tags")
|
||||
.and_then(|t| t.get("language").or_else(|| t.get("LANGUAGE")))
|
||||
.or_else(|| obj.get("language"))
|
||||
.and_then(|l| l.as_str())?;
|
||||
let lower = lang.trim().to_lowercase();
|
||||
if lower.is_empty() || lower == "und" {
|
||||
None
|
||||
} else {
|
||||
Some(Arc::<str>::from(lower))
|
||||
}
|
||||
});
|
||||
|
||||
self.probe.rcu(|current| {
|
||||
let mut map = (**current).clone();
|
||||
let (resolution, codec, bitrate_kbps) =
|
||||
map.get(key).map_or((VideoResolution::default(), VideoCodec::default(), 0), |p| {
|
||||
(p.resolution.clone(), p.codec.clone(), p.bitrate_kbps)
|
||||
});
|
||||
map.insert(
|
||||
key.clone(),
|
||||
ProbePart {
|
||||
resolution,
|
||||
codec,
|
||||
bitrate_kbps,
|
||||
audio_codec: audio_codec.clone(),
|
||||
audio_channels: audio_channels.clone(),
|
||||
audio_language: audio_language.clone(),
|
||||
},
|
||||
);
|
||||
Arc::new(map)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn admit(&self, key: &MemberKey, now: Instant) -> CircuitAdmission {
|
||||
let mut acquired_input_half_open = false;
|
||||
|
||||
// 1. Check input circuit first
|
||||
if let Some(mut input_entry) = self.shared.input_circuits.get_mut(&key.0) {
|
||||
match input_entry.circuit.state {
|
||||
CircuitState::Closed => {}
|
||||
CircuitState::OpenUntil(until) => {
|
||||
if now < until {
|
||||
return CircuitAdmission::Open;
|
||||
}
|
||||
input_entry.circuit.state = CircuitState::HalfOpenInFlight;
|
||||
acquired_input_half_open = true;
|
||||
}
|
||||
CircuitState::HalfOpenInFlight => {
|
||||
return CircuitAdmission::Open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check member circuit
|
||||
let mut revert_input = false;
|
||||
let admission = match self.shared.circuits.get_mut(key) {
|
||||
None => {
|
||||
if acquired_input_half_open {
|
||||
CircuitAdmission::HalfOpen(HalfOpenGuard {
|
||||
member_key: (Arc::clone(&key.0), Arc::clone(&key.1)),
|
||||
input_name: Some(Arc::clone(&key.0)),
|
||||
admit_time: now,
|
||||
shared: Arc::clone(&self.shared),
|
||||
consumed: false,
|
||||
})
|
||||
} else {
|
||||
CircuitAdmission::Closed
|
||||
}
|
||||
}
|
||||
Some(mut member_entry) => match member_entry.state {
|
||||
CircuitState::Closed => {
|
||||
if acquired_input_half_open {
|
||||
CircuitAdmission::HalfOpen(HalfOpenGuard {
|
||||
member_key: (Arc::clone(&key.0), Arc::clone(&key.1)),
|
||||
input_name: Some(Arc::clone(&key.0)),
|
||||
admit_time: now,
|
||||
shared: Arc::clone(&self.shared),
|
||||
consumed: false,
|
||||
})
|
||||
} else {
|
||||
CircuitAdmission::Closed
|
||||
}
|
||||
}
|
||||
CircuitState::OpenUntil(until) => {
|
||||
if now < until {
|
||||
revert_input = true;
|
||||
CircuitAdmission::Open
|
||||
} else {
|
||||
member_entry.state = CircuitState::HalfOpenInFlight;
|
||||
self.emit_circuit_changed(
|
||||
Arc::clone(&key.0),
|
||||
Arc::clone(&key.1),
|
||||
CircuitState::HalfOpenInFlight,
|
||||
);
|
||||
CircuitAdmission::HalfOpen(HalfOpenGuard {
|
||||
member_key: (Arc::clone(&key.0), Arc::clone(&key.1)),
|
||||
input_name: if acquired_input_half_open { Some(Arc::clone(&key.0)) } else { None },
|
||||
admit_time: now,
|
||||
shared: Arc::clone(&self.shared),
|
||||
consumed: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpenInFlight => {
|
||||
revert_input = true;
|
||||
CircuitAdmission::Open
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if revert_input && acquired_input_half_open {
|
||||
if let Some(mut input_entry) = self.shared.input_circuits.get_mut(&key.0) {
|
||||
if input_entry.circuit.state == CircuitState::HalfOpenInFlight {
|
||||
input_entry.circuit.state = CircuitState::OpenUntil(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
admission
|
||||
}
|
||||
|
||||
pub fn record_failure(&self, key: &MemberKey, now: Instant) {
|
||||
self.record_classified_failure(key, FailureClass::Stream, now);
|
||||
}
|
||||
|
||||
pub fn record_classified_failure(&self, key: &MemberKey, class: FailureClass, now: Instant) {
|
||||
let policy = self.policy.load();
|
||||
if class.opens_member_circuit() {
|
||||
let mut entry = self.shared.circuits.entry(key.clone()).or_default();
|
||||
entry.failures = entry.failures.saturating_add(1);
|
||||
if entry.failures >= policy.failures && entry.state == CircuitState::Closed {
|
||||
let cooldown = compute_cooldown(&policy, entry.open_level);
|
||||
entry.state = CircuitState::OpenUntil(now + cooldown);
|
||||
entry.open_level = entry.open_level.saturating_add(1);
|
||||
self.emit_circuit_changed(Arc::clone(&key.0), Arc::clone(&key.1), entry.state);
|
||||
}
|
||||
}
|
||||
if class.counts_for_input() {
|
||||
record_input_failure_in_shared(&self.shared, key, now, &policy);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_success(&self, key: &MemberKey) {
|
||||
if let Some(mut entry) = self.shared.circuits.get_mut(key) {
|
||||
let was_open = entry.state != CircuitState::Closed;
|
||||
entry.failures = 0;
|
||||
entry.open_level = 0;
|
||||
entry.state = CircuitState::Closed;
|
||||
if was_open {
|
||||
self.emit_circuit_changed(Arc::clone(&key.0), Arc::clone(&key.1), CircuitState::Closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure_with(&self, guard: HalfOpenGuard, now: Instant) {
|
||||
self.record_classified_failure_with(guard, FailureClass::Stream, now);
|
||||
}
|
||||
|
||||
pub fn record_classified_failure_with(&self, mut guard: HalfOpenGuard, class: FailureClass, now: Instant) {
|
||||
guard.consumed = true;
|
||||
let policy = self.policy.load();
|
||||
if class.opens_member_circuit() {
|
||||
let mut entry = self.shared.circuits.entry(guard.member_key.clone()).or_default();
|
||||
let cooldown = compute_cooldown(&policy, entry.open_level);
|
||||
entry.state = CircuitState::OpenUntil(now + cooldown);
|
||||
entry.open_level = entry.open_level.saturating_add(1);
|
||||
self.emit_circuit_changed(Arc::clone(&guard.member_key.0), Arc::clone(&guard.member_key.1), entry.state);
|
||||
}
|
||||
if let Some(input) = &guard.input_name {
|
||||
if class.counts_for_input() {
|
||||
let mut entry = self.shared.input_circuits.entry(Arc::clone(input)).or_default();
|
||||
let cooldown = compute_cooldown(&policy, entry.circuit.open_level);
|
||||
entry.circuit.state = CircuitState::OpenUntil(now + cooldown);
|
||||
entry.circuit.open_level = entry.circuit.open_level.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if class.counts_for_input() {
|
||||
record_input_failure_in_shared(&self.shared, &guard.member_key, now, &policy);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_success_with(&self, mut guard: HalfOpenGuard) {
|
||||
guard.consumed = true;
|
||||
if let Some(mut entry) = self.shared.circuits.get_mut(&guard.member_key) {
|
||||
let was_open = entry.state != CircuitState::Closed;
|
||||
entry.failures = 0;
|
||||
entry.open_level = 0;
|
||||
entry.state = CircuitState::Closed;
|
||||
if was_open {
|
||||
self.emit_circuit_changed(
|
||||
Arc::clone(&guard.member_key.0),
|
||||
Arc::clone(&guard.member_key.1),
|
||||
CircuitState::Closed,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(input) = &guard.input_name {
|
||||
if let Some(mut entry) = self.shared.input_circuits.get_mut(input) {
|
||||
entry.circuit.failures = 0;
|
||||
entry.circuit.open_level = 0;
|
||||
entry.circuit.state = CircuitState::Closed;
|
||||
entry.recent.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn circuit_view(&self, key: &MemberKey, now: Instant) -> CircuitView {
|
||||
if let Some(input_entry) = self.shared.input_circuits.get(&key.0) {
|
||||
match input_entry.circuit.state {
|
||||
CircuitState::OpenUntil(until) => {
|
||||
if now < until {
|
||||
return CircuitView::Open { remaining: until.saturating_duration_since(now) };
|
||||
}
|
||||
return CircuitView::Open { remaining: Duration::ZERO };
|
||||
}
|
||||
CircuitState::HalfOpenInFlight => {
|
||||
return CircuitView::HalfOpenInFlight;
|
||||
}
|
||||
CircuitState::Closed => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(entry) = self.shared.circuits.get(key) else {
|
||||
return CircuitView::Closed;
|
||||
};
|
||||
match entry.state {
|
||||
CircuitState::Closed => {
|
||||
if entry.failures > 0 {
|
||||
CircuitView::Counting(entry.failures)
|
||||
} else {
|
||||
CircuitView::Closed
|
||||
}
|
||||
}
|
||||
CircuitState::OpenUntil(until) => {
|
||||
if now < until {
|
||||
CircuitView::Open { remaining: until.saturating_duration_since(now) }
|
||||
} else {
|
||||
CircuitView::Open { remaining: Duration::ZERO }
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpenInFlight => CircuitView::HalfOpenInFlight,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_circuit_view(&self, input_name: &str, now: Instant) -> CircuitView {
|
||||
let Some(entry) = self.shared.input_circuits.get(input_name) else {
|
||||
return CircuitView::Closed;
|
||||
};
|
||||
match entry.circuit.state {
|
||||
CircuitState::Closed => {
|
||||
let policy = self.policy.load();
|
||||
let window = policy.input_window;
|
||||
let mut distinct = std::collections::HashSet::new();
|
||||
let mut count = 0usize;
|
||||
for (k, ts) in &entry.recent {
|
||||
let in_window = if *ts <= now { now.duration_since(*ts) <= window } else { true };
|
||||
if in_window {
|
||||
count += 1;
|
||||
distinct.insert(&k.1);
|
||||
}
|
||||
}
|
||||
if distinct.len() >= 2 && count > 0 {
|
||||
CircuitView::Counting(u8::try_from(count).unwrap_or(u8::MAX))
|
||||
} else {
|
||||
CircuitView::Closed
|
||||
}
|
||||
}
|
||||
CircuitState::OpenUntil(until) => {
|
||||
if now < until {
|
||||
CircuitView::Open { remaining: until.saturating_duration_since(now) }
|
||||
} else {
|
||||
CircuitView::Open { remaining: Duration::ZERO }
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpenInFlight => CircuitView::HalfOpenInFlight,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_policy(&self, policy: CircuitPolicy) { self.policy.store(Arc::new(policy)); }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::model::{PlaylistItemType, VirtualId, XtreamCluster};
|
||||
|
||||
fn qos(score_24h: u8, score_7d: u8, confidence: u8) -> QosPart {
|
||||
QosPart { score_24h, score_7d, confidence, failure_streak: 0 }
|
||||
}
|
||||
|
||||
fn test_member(input: &str, stream_id: &str, provider_id: u32, member_vid: u32) -> GroupMember {
|
||||
GroupMember {
|
||||
member_vid: VirtualId::new(member_vid),
|
||||
input_name: input.into(),
|
||||
provider_id,
|
||||
input_stream_id: stream_id.into(),
|
||||
url: format!("http://{input}.invalid/{stream_id}.ts").into(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
cluster: XtreamCluster::Live,
|
||||
title: "ZDF".into(),
|
||||
archive: None,
|
||||
epg_channel_id: None,
|
||||
additional_properties: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qos_lookup_uses_provider_id_or_target_member() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::default());
|
||||
let target: Arc<str> = "Main".into();
|
||||
let xtream = test_member("solis", "1", 1, 101);
|
||||
let m3u = test_member("m3u-in", "chan-a", 0, 202);
|
||||
index.replace_qos([
|
||||
(QosKey::Provider { input: "solis".into(), provider_id: 1 }, qos(80, 70, 20)),
|
||||
(QosKey::Provider { input: "solis".into(), provider_id: 1 }, qos(40, 50, 90)),
|
||||
(QosKey::TargetMember { target: Arc::clone(&target), member_vid: 202 }, qos(66, 66, 50)),
|
||||
]);
|
||||
let now = Instant::now();
|
||||
assert_eq!(index.health(&target, &xtream, now).qos.map(|q| q.score_24h), Some(40));
|
||||
assert_eq!(index.health(&target, &m3u, now).qos.map(|q| q.score_24h), Some(66));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_patch_keeps_qos() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::default());
|
||||
let target: Arc<str> = "Main".into();
|
||||
let member = test_member("solis", "1", 1, 101);
|
||||
index.replace_qos([(QosKey::Provider { input: "solis".into(), provider_id: 1 }, qos(40, 50, 90))]);
|
||||
index.patch_probe(&member_key(&member), Some(r#"{"codec_name":"hevc","width":3840,"height":2160}"#), 12_000);
|
||||
let health = index.health(&target, &member, Instant::now());
|
||||
assert_eq!(health.qos.map(|q| q.score_24h), Some(40));
|
||||
let probe = health.probe.expect("probe");
|
||||
assert_eq!(probe.resolution, VideoResolution::P2160);
|
||||
assert_eq!(probe.codec, VideoCodec::H265);
|
||||
assert_eq!(probe.bitrate_kbps, 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_patch_sets_codec_channels_and_language() {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::default());
|
||||
let target: Arc<str> = "Main".into();
|
||||
let member = test_member("solis", "1", 1, 101);
|
||||
index.patch_probe(&member_key(&member), Some(r#"{"codec_name":"h264","width":1920,"height":1080}"#), 5_000);
|
||||
index.patch_probe_audio(
|
||||
&member_key(&member),
|
||||
Some(r#"[{"codec_name":"ac3","channels":6,"tags":{"language":"DEU"}}]"#),
|
||||
);
|
||||
let probe = index.health(&target, &member, Instant::now()).probe.expect("probe");
|
||||
assert_eq!(probe.codec, VideoCodec::H264);
|
||||
assert_eq!(probe.audio_codec, AudioCodec::AC3);
|
||||
assert_eq!(probe.audio_channels, AudioChannels::Surround51);
|
||||
assert_eq!(probe.audio_language.as_deref(), Some("deu"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod circuit;
|
||||
pub mod health;
|
||||
pub mod ranking;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
|
||||
pub use circuit::*;
|
||||
pub use health::*;
|
||||
pub use ranking::*;
|
||||
@@ -0,0 +1,441 @@
|
||||
use crate::channel_group::{
|
||||
circuit::{CircuitView, MemberKey},
|
||||
health::{member_key, MemberHealthIndex},
|
||||
};
|
||||
use shared::{
|
||||
model::{AudioChannels, AudioCodec, ChannelGroupPolicy, RuntimeAudioLanguage, VideoCodec, VideoResolution},
|
||||
utils::quality_rank,
|
||||
};
|
||||
use std::{cmp::Reverse, sync::Arc, time::Instant};
|
||||
use tuliprox_repository::GroupMember;
|
||||
|
||||
pub const W_QOS: i32 = 6;
|
||||
pub const W_QUALITY: i32 = 3;
|
||||
pub const W_ORDER: i32 = 1;
|
||||
pub const NEUTRAL_QOS: u8 = 50;
|
||||
pub const OPEN_CIRCUIT_PENALTY: i32 = 100;
|
||||
pub const SAME_CODEC_BONUS: i32 = 10;
|
||||
pub const SAME_RESOLUTION_BONUS: i32 = 10;
|
||||
pub const SAME_AUDIO_BONUS: i32 = 5;
|
||||
pub const SAME_LANGUAGE_BONUS: i32 = 5;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioSignature {
|
||||
pub codec: AudioCodec,
|
||||
pub channels: AudioChannels,
|
||||
pub language: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SelectionMode {
|
||||
Live,
|
||||
Runtime {
|
||||
current_codec: VideoCodec,
|
||||
current_resolution: VideoResolution,
|
||||
current_audio: Option<AudioSignature>,
|
||||
audio_language: RuntimeAudioLanguage,
|
||||
},
|
||||
Archive {
|
||||
start_ts: i64,
|
||||
now_ts: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ScoreBreakdown {
|
||||
pub qos: u8,
|
||||
pub quality: u8,
|
||||
pub order: u8,
|
||||
pub total: u8,
|
||||
pub demoted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RankedCandidate {
|
||||
pub index: usize,
|
||||
pub key: MemberKey,
|
||||
pub score: ScoreBreakdown,
|
||||
}
|
||||
|
||||
fn filter_candidates<'a>(
|
||||
target_name: &Arc<str>,
|
||||
members: &'a [GroupMember],
|
||||
health: &MemberHealthIndex,
|
||||
mode: &SelectionMode,
|
||||
now: Instant,
|
||||
) -> Vec<(usize, &'a GroupMember)> {
|
||||
let mut candidates = Vec::with_capacity(members.len());
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
match mode {
|
||||
SelectionMode::Live => {
|
||||
candidates.push((i, member));
|
||||
}
|
||||
SelectionMode::Archive { start_ts, now_ts } => {
|
||||
if let Some(archive) = member.archive {
|
||||
let earliest_ts = now_ts.saturating_sub(i64::from(archive.duration_days) * 86_400);
|
||||
if *start_ts >= earliest_ts {
|
||||
candidates.push((i, member));
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectionMode::Runtime { current_audio, audio_language, .. } => {
|
||||
if *audio_language == RuntimeAudioLanguage::Require {
|
||||
if let Some(target_lang) = current_audio.as_ref().and_then(|a| a.language.as_deref()) {
|
||||
let m_health = health.health(target_name, member, now);
|
||||
if let Some(cand_lang) = m_health.probe.as_ref().and_then(|p| p.audio_language.as_deref()) {
|
||||
if !cand_lang.eq_ignore_ascii_case(target_lang) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.push((i, member));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn compute_quality_points(
|
||||
probe: Option<&crate::channel_group::health::ProbePart>,
|
||||
title: &str,
|
||||
min_bitrate: u32,
|
||||
max_bitrate: u32,
|
||||
) -> u8 {
|
||||
let (res_pts, bitrate_pts) = if let Some(probe) = probe {
|
||||
let res = match probe.resolution {
|
||||
VideoResolution::SD => 25,
|
||||
VideoResolution::P720 => 50,
|
||||
VideoResolution::P1080 => 75,
|
||||
VideoResolution::P1440 | VideoResolution::P2160 | VideoResolution::P4320 => 100,
|
||||
VideoResolution::Unknown => match quality_rank(title) {
|
||||
1 => 25,
|
||||
2 => 50,
|
||||
3 => 75,
|
||||
4 | 5 => 100,
|
||||
_ => 0,
|
||||
},
|
||||
};
|
||||
let b_pts = if max_bitrate > min_bitrate {
|
||||
u32::try_from(
|
||||
10 * u64::from(probe.bitrate_kbps.saturating_sub(min_bitrate)) / u64::from(max_bitrate - min_bitrate),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(res, b_pts)
|
||||
} else {
|
||||
let res = match quality_rank(title) {
|
||||
1 => 25,
|
||||
2 => 50,
|
||||
3 => 75,
|
||||
4 | 5 => 100,
|
||||
_ => 0,
|
||||
};
|
||||
(res, 0)
|
||||
};
|
||||
u8::try_from((res_pts + bitrate_pts).min(100)).unwrap_or(100)
|
||||
}
|
||||
|
||||
fn compute_runtime_bonuses(probe: Option<&crate::channel_group::health::ProbePart>, mode: &SelectionMode) -> i32 {
|
||||
let SelectionMode::Runtime { current_codec, current_resolution, current_audio, .. } = mode else {
|
||||
return 0;
|
||||
};
|
||||
let Some(probe) = probe else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let mut bonuses = 0i32;
|
||||
if *current_codec != VideoCodec::Other && probe.codec == *current_codec {
|
||||
bonuses += SAME_CODEC_BONUS;
|
||||
}
|
||||
if *current_resolution != VideoResolution::Unknown && probe.resolution == *current_resolution {
|
||||
bonuses += SAME_RESOLUTION_BONUS;
|
||||
}
|
||||
if let Some(curr_a) = current_audio {
|
||||
if curr_a.codec != AudioCodec::Other
|
||||
&& probe.audio_codec == curr_a.codec
|
||||
&& probe.audio_channels == curr_a.channels
|
||||
{
|
||||
bonuses += SAME_AUDIO_BONUS;
|
||||
}
|
||||
if let Some(curr_lang) = &curr_a.language {
|
||||
if let Some(cand_lang) = &probe.audio_language {
|
||||
if cand_lang.eq_ignore_ascii_case(curr_lang) {
|
||||
bonuses += SAME_LANGUAGE_BONUS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bonuses
|
||||
}
|
||||
|
||||
pub fn rank_members(
|
||||
target_name: &Arc<str>,
|
||||
members: &[GroupMember],
|
||||
policy: ChannelGroupPolicy,
|
||||
health: &MemberHealthIndex,
|
||||
mode: &SelectionMode,
|
||||
now: Instant,
|
||||
) -> Vec<RankedCandidate> {
|
||||
if members.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let candidates = filter_candidates(target_name, members, health, mode, now);
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let member_healths: Vec<_> = candidates.iter().map(|(_, m)| health.health(target_name, m, now)).collect();
|
||||
|
||||
let mut max_bitrate = 0u32;
|
||||
let mut min_bitrate = u32::MAX;
|
||||
for h in &member_healths {
|
||||
let b = h.probe.as_ref().map_or(0, |p| p.bitrate_kbps);
|
||||
max_bitrate = max_bitrate.max(b);
|
||||
min_bitrate = min_bitrate.min(b);
|
||||
}
|
||||
if min_bitrate == u32::MAX {
|
||||
min_bitrate = 0;
|
||||
}
|
||||
|
||||
let mut ranked = Vec::with_capacity(candidates.len());
|
||||
for ((index, member), m_health) in candidates.into_iter().zip(member_healths) {
|
||||
let key = member_key(member);
|
||||
|
||||
let member_circuit_open = matches!(m_health.circuit, CircuitView::Open { .. });
|
||||
let input_circuit_open = matches!(health.input_circuit_view(&member.input_name, now), CircuitView::Open { .. });
|
||||
let high_failure_streak = m_health.qos.is_some_and(|q| q.failure_streak >= 3);
|
||||
let demoted = member_circuit_open || input_circuit_open || high_failure_streak;
|
||||
|
||||
let qos = m_health.qos.map_or(NEUTRAL_QOS, |q| {
|
||||
let conf = u32::from(q.confidence);
|
||||
let s24 = u32::from(q.score_24h);
|
||||
let s7d = u32::from(q.score_7d);
|
||||
u8::try_from((s24 * conf + s7d * (100 - conf)) / 100).unwrap_or(0)
|
||||
});
|
||||
|
||||
let quality = compute_quality_points(m_health.probe.as_ref(), member.title.as_ref(), min_bitrate, max_bitrate);
|
||||
let order = u8::try_from(100 - (100 * index / members.len())).unwrap_or(0);
|
||||
let base_total = (W_QOS * i32::from(qos) + W_QUALITY * i32::from(quality) + W_ORDER * i32::from(order)) / 10;
|
||||
let bonuses = compute_runtime_bonuses(m_health.probe.as_ref(), mode);
|
||||
let penalty = if member_circuit_open || input_circuit_open { OPEN_CIRCUIT_PENALTY } else { 0 };
|
||||
let total = u8::try_from((base_total + bonuses - penalty).clamp(0, 100)).unwrap_or(0);
|
||||
|
||||
ranked.push(RankedCandidate { index, key, score: ScoreBreakdown { qos, quality, order, total, demoted } });
|
||||
}
|
||||
|
||||
match policy {
|
||||
ChannelGroupPolicy::Auto => {
|
||||
ranked.sort_by_key(|c| (c.score.demoted, Reverse(c.score.total), c.index));
|
||||
}
|
||||
ChannelGroupPolicy::Priority => {
|
||||
ranked.sort_by_key(|c| (c.score.demoted, c.index));
|
||||
}
|
||||
}
|
||||
|
||||
ranked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channel_group::{
|
||||
circuit::{CircuitPolicy, FailureClass},
|
||||
health::{member_key, QosKey, QosPart},
|
||||
test_support::test_member,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tuliprox_repository::MemberArchive;
|
||||
|
||||
const TARGET: &str = "Main";
|
||||
|
||||
fn fixture(spec: &[(&str, u32, Option<u8>, &str)]) -> (Vec<GroupMember>, MemberHealthIndex) {
|
||||
let index = MemberHealthIndex::new(CircuitPolicy::new(1, Duration::from_secs(60), Duration::from_secs(60)));
|
||||
let mut members = Vec::new();
|
||||
for (i, (input, provider_id, qos_24h, probe)) in spec.iter().enumerate() {
|
||||
let member = test_member(input, &provider_id.to_string(), *provider_id, 100 + u32::try_from(i).unwrap());
|
||||
if let Some(score) = qos_24h {
|
||||
index.replace_qos_merge([(
|
||||
QosKey::Provider { input: (*input).into(), provider_id: *provider_id },
|
||||
QosPart { score_24h: *score, score_7d: *score, confidence: 100, failure_streak: 0 },
|
||||
)]);
|
||||
}
|
||||
if !probe.is_empty() {
|
||||
let (codec, dims) = probe.split_once(':').unwrap_or(("h264", probe));
|
||||
let (w, h) = dims.split_once('x').expect("WxH");
|
||||
let json = format!(r#"{{"codec_name":"{codec}","width":{w},"height":{h}}}"#);
|
||||
index.patch_probe(&member_key(&member), Some(&json), 5_000);
|
||||
}
|
||||
members.push(member);
|
||||
}
|
||||
(members, index)
|
||||
}
|
||||
|
||||
fn fixture_names(titles: &[&str]) -> (Vec<GroupMember>, MemberHealthIndex) {
|
||||
let (mut members, index) = fixture(
|
||||
&titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| ("solis", u32::try_from(i + 1).unwrap(), None, ""))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
for (member, title) in members.iter_mut().zip(titles) {
|
||||
member.title = (*title).into();
|
||||
}
|
||||
(members, index)
|
||||
}
|
||||
|
||||
fn open_circuit(index: &MemberHealthIndex, member: &GroupMember) {
|
||||
index.record_failure(&member_key(member), Instant::now());
|
||||
}
|
||||
|
||||
fn rank(
|
||||
members: &[GroupMember],
|
||||
policy: ChannelGroupPolicy,
|
||||
index: &MemberHealthIndex,
|
||||
mode: &SelectionMode,
|
||||
) -> Vec<RankedCandidate> {
|
||||
rank_members(&Arc::from(TARGET), members, policy, index, mode, Instant::now())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_prefers_uhd_over_one_point_better_qos() {
|
||||
let (members, health) = fixture(&[("solis", 1, Some(81), "1920x1080"), ("korath", 7, Some(80), "3840x2160")]);
|
||||
assert_eq!(rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live)[0].index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_prefers_much_better_qos_over_resolution() {
|
||||
let (members, health) = fixture(&[("solis", 1, Some(95), "1920x1080"), ("korath", 7, Some(30), "3840x2160")]);
|
||||
assert_eq!(rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live)[0].index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_breakdown_uses_fixed_weights() {
|
||||
let (members, health) = fixture(&[("solis", 1, Some(100), "3840x2160")]);
|
||||
let ranked = rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live);
|
||||
assert_eq!(
|
||||
(ranked[0].score.qos, ranked[0].score.quality, ranked[0].score.order, ranked[0].score.total),
|
||||
(100, 100, 100, 100)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_data_is_neutral_and_uses_name_quality() {
|
||||
let (members, health) = fixture_names(&["ZDF SD", "ZDF FHD"]);
|
||||
let ranked = rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live);
|
||||
assert_eq!(ranked[0].index, 1);
|
||||
assert_eq!(ranked[0].score.qos, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_keeps_order_but_demotes_open_circuit_to_end() {
|
||||
let (members, health) =
|
||||
fixture(&[("solis", 1, Some(10), "720x576"), ("korath", 7, Some(99), "3840x2160"), ("veyra", 3, None, "")]);
|
||||
open_circuit(&health, &members[0]);
|
||||
let ranked = rank(&members, ChannelGroupPolicy::Priority, &health, &SelectionMode::Live);
|
||||
assert_eq!(ranked.iter().map(|c| c.index).collect::<Vec<_>>(), vec![1, 2, 0]);
|
||||
assert!(ranked[2].score.demoted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_input_circuit_demotes_all_members_of_that_input() {
|
||||
let (members, health) =
|
||||
fixture(&[("solis", 1, Some(99), ""), ("solis", 2, Some(99), ""), ("korath", 7, Some(40), "")]);
|
||||
health.set_policy(
|
||||
CircuitPolicy::new(5, Duration::from_secs(60), Duration::from_secs(60))
|
||||
.with_input(2, Duration::from_secs(120)),
|
||||
);
|
||||
let now = Instant::now();
|
||||
health.record_classified_failure(&member_key(&members[0]), FailureClass::Transport, now);
|
||||
health.record_classified_failure(&member_key(&members[1]), FailureClass::Transport, now);
|
||||
let ranked = rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live);
|
||||
assert_eq!(ranked[0].index, 2);
|
||||
assert!(ranked[1].score.demoted && ranked[2].score.demoted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_circuit_penalty_saturates_at_zero() {
|
||||
let (members, health) = fixture(&[("solis", 1, Some(0), "720x576")]);
|
||||
open_circuit(&health, &members[0]);
|
||||
assert_eq!(rank(&members, ChannelGroupPolicy::Auto, &health, &SelectionMode::Live)[0].score.total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_mode_prefers_same_codec() {
|
||||
let (members, health) =
|
||||
fixture(&[("solis", 1, Some(70), "hevc:3840x2160"), ("korath", 7, Some(70), "h264:1920x1080")]);
|
||||
let mode = SelectionMode::Runtime {
|
||||
current_codec: VideoCodec::H264,
|
||||
current_resolution: VideoResolution::P1080,
|
||||
current_audio: None,
|
||||
audio_language: RuntimeAudioLanguage::Prefer,
|
||||
};
|
||||
assert_eq!(rank(&members, ChannelGroupPolicy::Auto, &health, &mode)[0].index, 1);
|
||||
}
|
||||
|
||||
fn set_audio(health: &MemberHealthIndex, member: &GroupMember, json: &str) {
|
||||
health.patch_probe_audio(&member_key(member), Some(json));
|
||||
}
|
||||
|
||||
fn runtime_with_audio(language: Option<&str>, policy: RuntimeAudioLanguage) -> SelectionMode {
|
||||
SelectionMode::Runtime {
|
||||
current_codec: VideoCodec::H264,
|
||||
current_resolution: VideoResolution::P1080,
|
||||
current_audio: Some(AudioSignature {
|
||||
codec: AudioCodec::AC3,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: language.map(Into::into),
|
||||
}),
|
||||
audio_language: policy,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_mode_prefers_same_audio_and_language() {
|
||||
let (members, health) =
|
||||
fixture(&[("solis", 1, Some(70), "h264:1920x1080"), ("korath", 7, Some(70), "h264:1920x1080")]);
|
||||
set_audio(&health, &members[0], r#"[{"codec_name":"aac","channels":2,"tags":{"language":"eng"}}]"#);
|
||||
set_audio(&health, &members[1], r#"[{"codec_name":"ac3","channels":6,"tags":{"language":"deu"}}]"#);
|
||||
let ranked = rank(
|
||||
&members,
|
||||
ChannelGroupPolicy::Auto,
|
||||
&health,
|
||||
&runtime_with_audio(Some("deu"), RuntimeAudioLanguage::Prefer),
|
||||
);
|
||||
assert_eq!(ranked[0].index, 1);
|
||||
assert_eq!(ranked.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_language_drops_known_mismatch_but_keeps_unknown() {
|
||||
let (members, health) =
|
||||
fixture(&[("solis", 1, Some(90), ""), ("korath", 7, Some(50), ""), ("veyra", 3, Some(40), "")]);
|
||||
set_audio(&health, &members[0], r#"[{"codec_name":"aac","channels":2,"tags":{"language":"eng"}}]"#);
|
||||
set_audio(&health, &members[1], r#"[{"codec_name":"aac","channels":2}]"#); // unknown language
|
||||
set_audio(&health, &members[2], r#"[{"codec_name":"aac","channels":2,"tags":{"language":"deu"}}]"#);
|
||||
let ranked = rank(
|
||||
&members,
|
||||
ChannelGroupPolicy::Auto,
|
||||
&health,
|
||||
&runtime_with_audio(Some("deu"), RuntimeAudioLanguage::Require),
|
||||
);
|
||||
assert_eq!(ranked.iter().map(|c| c.index).collect::<Vec<_>>(), vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_mode_filters_members_not_covering_start() {
|
||||
let (mut members, health) = fixture(&[("solis", 1, Some(90), ""), ("korath", 7, Some(50), "")]);
|
||||
members[0].archive = Some(MemberArchive { duration_days: 1 });
|
||||
members[1].archive = Some(MemberArchive { duration_days: 7 });
|
||||
let now_ts = 1_700_000_000;
|
||||
let mode = SelectionMode::Archive { start_ts: now_ts - 3 * 86_400, now_ts };
|
||||
assert_eq!(
|
||||
rank(&members, ChannelGroupPolicy::Auto, &health, &mode).iter().map(|c| c.index).collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use shared::model::{PlaylistItemType, VirtualId, XtreamCluster};
|
||||
use tuliprox_repository::GroupMember;
|
||||
|
||||
pub fn test_member(input: &str, stream_id: &str, provider_id: u32, member_vid: u32) -> GroupMember {
|
||||
GroupMember {
|
||||
member_vid: VirtualId::new(member_vid),
|
||||
input_name: input.into(),
|
||||
provider_id,
|
||||
input_stream_id: stream_id.into(),
|
||||
url: format!("http://{input}.invalid/{stream_id}.ts").into(),
|
||||
item_type: PlaylistItemType::Live,
|
||||
cluster: XtreamCluster::Live,
|
||||
title: "ZDF".into(),
|
||||
archive: None,
|
||||
epg_channel_id: None,
|
||||
additional_properties: None,
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ fn make_stream_info(provider: &str, title: &str) -> StreamInfo {
|
||||
epg_channel_id: None,
|
||||
epg_reference_ts: None,
|
||||
upstream_user_agent: None,
|
||||
group_virtual_id: None,
|
||||
};
|
||||
StreamInfo::new(shared::model::StreamInfoParams {
|
||||
uid: 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user